fix(ssh): stop floating terminal metadata from blocking SSH access links (#31963)

* fix(ssh): stop local-only workspace metadata from blocking SSH access links

Unstamped floating and setup terminal metadata rows were projected onto every
execution host, so the dependent-state census refused SSH access links and
empty-host claims for every SSH target with orcad_migration_dependent_state.

- Local-only workspace ids project only onto the local host, are stamped local
  on load, and their metadata writes always land on the local host.
- The census counts an unstamped legacy row only when its repo is not
  registered solely on other hosts.
- The SSH access error now names the blocking state.

Builds on the approach in #31862 by Frank-zhu2000.

Fixes #31498

Co-authored-by: Frank-zhu2000 <Frank-zhu2000@users.noreply.github.com>

* fix(ssh): rehome remote-stamped local-only metadata, skip orphan rows, and word the SSH access error for a link

* fix(persistence): keep a local or unstamped floating row over a remote copy when rehoming

* fix(ssh): make the SSH access blocker copy switch exhaustive

---------

Co-authored-by: Frank-zhu2000 <Frank-zhu2000@users.noreply.github.com>
This commit is contained in:
Neil
2026-10-22 03:39:54 -07:00
committed by GitHub
co-authored by Frank-zhu2000
parent 2c937546dd
commit 92c8f3cbdc
12 changed files with 472 additions and 18 deletions
@@ -127,6 +127,7 @@ let flushes: number
function setupStore(overrides: { repos?: { connectionId: string }[] } = {}) {
const store = {
...emptyDependentStateStore(),
allocateSshTargetGeneration: () => 9,
flushPendingOrThrowAsync: async () => {
flushes += 1
@@ -134,7 +135,6 @@ function setupStore(overrides: { repos?: { connectionId: string }[] } = {}) {
},
getFolderWorkspaces: () => [],
getRepos: () => overrides.repos ?? [],
...emptyDependentStateStore(),
getSshTarget: (id: string) => (id === target.id ? target : undefined),
getSshTargets: () => [target],
updateSshTarget: (_id: string, updates: Partial<SshTarget>) => {
@@ -568,13 +568,13 @@ describe('createManagedOrcadEnvironment', () => {
it('does not contact the host when the claim cannot be made durable', async () => {
const claims = new SshTargetOrcadClaims({
...emptyDependentStateStore(),
allocateSshTargetGeneration: () => 9,
flushPendingOrThrowAsync: async () => {
throw new Error('disk full')
},
getFolderWorkspaces: () => [],
getRepos: () => [],
...emptyDependentStateStore(),
getSshTarget: () => target,
getSshTargets: () => [target],
updateSshTarget: (_id, updates) => (target = { ...target, ...updates })
@@ -0,0 +1,145 @@
import {
closeTestStores,
createStore,
readPersistedStateJson,
testState
} from './persistence-test-harness'
/**
* The floating and setup terminals have no repo row, so their metadata belongs to this store's own
* machine. An older build left them unstamped, which projected them onto every SSH host (#31498).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultPersistedState } from '../shared/constants'
import {
composeWorktreeHostIdentity,
hostWorktreeTarget
} from '../shared/worktree/host-qualified-identity'
import { canonicalWorktreeIdentity } from '../shared/worktree/identity'
import { resolveWorktreeMetaOwner } from './persistence/host-qualified-worktree-meta'
import { hydrateWorktreeMetaAliasProjection } from './persistence/loading-store/worktree-meta-alias-projection'
import { normalizeWorktreeLinkedItemMetadata } from './persistence/tracking-repos/worktree-metadata-normalization'
import { collectDependentStateBlockers } from './ssh/targets/ssh-target-orcad-dependents'
vi.mock('electron', () => ({
app: { getPath: () => testState.dir },
safeStorage: { isEncryptionAvailable: () => false }
}))
vi.mock('./telemetry/client', () => ({ track: vi.fn() }))
vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() }))
const SSH_HOST = 'ssh:ssh-1'
const INSTANCE_ID = '11111111-1111-4111-8111-111111111111'
const floatingRow = {
instanceId: INSTANCE_ID,
displayName: '',
comment: '',
isUnread: false,
sortOrder: 1,
lastActivityAt: 1,
workspaceStatus: 'in-progress'
}
function createUpgradedStore(metadata: Record<string, unknown> = {}) {
mkdirSync(testState.dir, { recursive: true })
writeFileSync(
join(testState.dir, 'orca-data.json'),
JSON.stringify({
...getDefaultPersistedState(testState.dir),
worktreeMeta: { [FLOATING_TERMINAL_WORKTREE_ID]: floatingRow },
...metadata
}),
'utf-8'
)
return createStore()
}
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-local-only-worktree-meta-'))
})
afterEach(async () => {
await closeTestStores()
rmSync(testState.dir, { recursive: true, force: true })
})
describe('local-only workspace metadata', () => {
it('stamps a pre-existing unstamped floating terminal row local on load', () => {
const store = createUpgradedStore()
expect(store.getWorktreeMeta(FLOATING_TERMINAL_WORKTREE_ID)?.hostId).toBe('local')
expect(store.getAllWorktreeMetaForHost(SSH_HOST)).not.toHaveProperty(
FLOATING_TERMINAL_WORKTREE_ID
)
expect(store.getWorktreeMetaForHost(FLOATING_TERMINAL_WORKTREE_ID, 'local')?.sortOrder).toBe(1)
expect(collectDependentStateBlockers(store, 'ssh-1')).toEqual([])
})
it('moves a floating terminal row an older build stamped for an SSH host back to this machine', () => {
const remoteKey = canonicalWorktreeIdentity({
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
executionHostId: SSH_HOST,
instanceId: INSTANCE_ID
})
const remoteRow = { ...floatingRow, hostId: SSH_HOST }
const store = createUpgradedStore({
worktreeMeta: { [FLOATING_TERMINAL_WORKTREE_ID]: remoteRow },
worktreeMetaByIdentity: { [remoteKey]: remoteRow },
worktreeIdentityAliases: {
[composeWorktreeHostIdentity(SSH_HOST, FLOATING_TERMINAL_WORKTREE_ID)]: [remoteKey]
}
})
expect(store.getAllWorktreeMetaForHost(SSH_HOST)).toEqual({})
expect(store.getWorktreeMetaForHost(FLOATING_TERMINAL_WORKTREE_ID, 'local')).toMatchObject({
hostId: 'local',
instanceId: INSTANCE_ID,
workspaceStatus: 'in-progress'
})
expect(collectDependentStateBlockers(store, 'ssh-1')).toEqual([])
})
it('keeps a local floating terminal row over a staler remote copy, and settles in one load', async () => {
const remoteKey = canonicalWorktreeIdentity({
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
executionHostId: SSH_HOST,
instanceId: INSTANCE_ID
})
const localRow = { ...floatingRow, hostId: 'local', lastActivityAt: 5, workspaceStatus: 'done' }
createUpgradedStore({
worktreeMeta: { [FLOATING_TERMINAL_WORKTREE_ID]: localRow },
worktreeMetaByIdentity: { [remoteKey]: { ...floatingRow, hostId: SSH_HOST } },
worktreeIdentityAliases: {
[composeWorktreeHostIdentity(SSH_HOST, FLOATING_TERMINAL_WORKTREE_ID)]: [remoteKey]
}
}).flush()
await closeTestStores()
const saved = JSON.parse(readPersistedStateJson())
hydrateWorktreeMetaAliasProjection(saved)
expect(normalizeWorktreeLinkedItemMetadata(saved)).toBe(false)
const reloaded = createStore()
expect(
reloaded.getAllWorktreeMetaForHost('local')[FLOATING_TERMINAL_WORKTREE_ID]
).toMatchObject({ hostId: 'local', lastActivityAt: 5, workspaceStatus: 'done' })
expect(reloaded.getAllWorktreeMetaForHost(SSH_HOST)).toEqual({})
})
it('lands every floating terminal write on this machine, whichever host the caller named', () => {
const store = createUpgradedStore()
expect(resolveWorktreeMetaOwner(store, FLOATING_TERMINAL_WORKTREE_ID)?.hostId).toBe('local')
store.setWorktreeMeta(
hostWorktreeTarget({ id: FLOATING_TERMINAL_WORKTREE_ID, hostId: SSH_HOST }),
{
isUnread: true
}
)
expect(store.getAllWorktreeMetaForHost('local')[FLOATING_TERMINAL_WORKTREE_ID]).toMatchObject({
hostId: 'local',
isUnread: true
})
expect(store.getAllWorktreeMetaForHost(SSH_HOST)).toEqual({})
expect(collectDependentStateBlockers(store, 'ssh-1')).toEqual([])
})
})
@@ -1,5 +1,10 @@
import { getRepoRegistrationHostIds } from '../../shared/repo-host-identity'
import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
import {
getRepoExecutionHostId,
LOCAL_EXECUTION_HOST_ID,
type ExecutionHostId
} from '../../shared/execution-host'
import { isLocalOnlyWorktreeMetaId } from '../../shared/worktree/local-only-worktree-meta-ids'
import type { Repo } from '../../shared/repo-types'
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
import { getRepoIdFromWorktreeId } from '../../shared/worktree/id'
@@ -84,6 +89,10 @@ export function resolveWorktreeMetaOwner(
},
worktreeId: string
): HostWorktreeTarget | undefined {
// Why: no repo stands behind these ids, so without this their id-only writes were dropped.
if (isLocalOnlyWorktreeMetaId(worktreeId)) {
return hostWorktreeTarget({ id: worktreeId, hostId: LOCAL_EXECUTION_HOST_ID })
}
const repoId = getRepoIdFromWorktreeId(worktreeId)
const owners = store.getRepos().filter((repo) => repo.id === repoId)
// Why: the raw-id row's stamp names whichever twin wrote first, not the twin the caller means.
@@ -0,0 +1,108 @@
import { randomUUID } from 'node:crypto'
import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
import type { PersistedState } from '../../../shared/persisted-state-types'
import type { WorktreeMeta } from '../../../shared/worktree/meta-types'
import { canonicalWorktreeIdentity } from '../../../shared/worktree/identity'
import {
composeWorktreeHostIdentity,
getExecutionHostIdFromWorktreeHostIdentity,
getWorktreeIdFromHostIdentity
} from '../../../shared/worktree/host-qualified-identity'
import { isLocalOnlyWorktreeMetaId } from '../../../shared/worktree/local-only-worktree-meta-ids'
import {
pruneUnreferencedWorktreeIdentityMeta,
resolveAliasIdentityKey
} from './worktree-identity-metadata'
/**
* Older builds left floating and setup terminal rows unstamped, or stamped them with whichever host
* read them first. Either way the row projected onto a remote host and blocked its SSH access
* (#31498), and a remote stamp hid it from this machine. Load moves them home: an existing local
* or unstamped row wins, otherwise the newest remote one is re-keyed for the local host.
*/
export function rehomeLocalOnlyWorktreeMetadata(state: PersistedState): boolean {
let changed = false
const dropped = new Set<string>()
const newestRemote = new Map<string, { identityKey: string; meta: WorktreeMeta }>()
for (const alias of Object.keys(state.worktreeIdentityAliases ?? {})) {
const worktreeId = getWorktreeIdFromHostIdentity(alias)
if (
!isLocalOnlyWorktreeMetaId(worktreeId) ||
getExecutionHostIdFromWorktreeHostIdentity(alias) === LOCAL_EXECUTION_HOST_ID
) {
continue
}
const identityKey = resolveAliasIdentityKey(state, alias)
const meta = identityKey ? state.worktreeMetaByIdentity?.[identityKey] : undefined
const newest = newestRemote.get(worktreeId)
if (identityKey && meta && (!newest || isNewer(identityKey, meta, newest))) {
newestRemote.set(worktreeId, { identityKey, meta })
}
for (const key of state.worktreeIdentityAliases?.[alias] ?? []) {
dropped.add(key)
}
delete state.worktreeIdentityAliases?.[alias]
changed = true
}
for (const [worktreeId, { meta }] of newestRemote) {
const localAlias = composeWorktreeHostIdentity(LOCAL_EXECUTION_HOST_ID, worktreeId)
if (
(state.worktreeIdentityAliases?.[localAlias] ?? []).length > 0 ||
isLocalLocatorRow(state, worktreeId)
) {
continue
}
const instanceId = meta.instanceId ?? randomUUID()
const localKey = canonicalWorktreeIdentity({
worktreeId,
executionHostId: LOCAL_EXECUTION_HOST_ID,
instanceId
})
state.worktreeMetaByIdentity ??= {}
state.worktreeIdentityAliases ??= {}
state.worktreeMetaByIdentity[localKey] = {
...meta,
instanceId,
hostId: LOCAL_EXECUTION_HOST_ID
}
state.worktreeIdentityAliases[localAlias] = [localKey]
}
for (const [worktreeId, meta] of Object.entries(state.worktreeMeta)) {
if (!isLocalOnlyWorktreeMetaId(worktreeId) || meta.hostId === LOCAL_EXECUTION_HOST_ID) {
continue
}
const localKey = resolveAliasIdentityKey(
state,
composeWorktreeHostIdentity(LOCAL_EXECUTION_HOST_ID, worktreeId)
)
// A fresh object: the old row may be shared with a remote identity row being dropped.
state.worktreeMeta[worktreeId] = (localKey && state.worktreeMetaByIdentity?.[localKey]) || {
...meta,
hostId: LOCAL_EXECUTION_HOST_ID
}
changed = true
}
if (dropped.size > 0) {
pruneUnreferencedWorktreeIdentityMeta(state, dropped)
}
return changed
}
// An unstamped locator row is what local reads returned before, so it outranks a remote copy.
function isLocalLocatorRow(state: PersistedState, worktreeId: string): boolean {
const locator = state.worktreeMeta[worktreeId]
return (
locator !== undefined && (locator.hostId ?? LOCAL_EXECUTION_HOST_ID) === LOCAL_EXECUTION_HOST_ID
)
}
// Same order as resolveAliasIdentityKey: newest activity, then the greater key.
function isNewer(
identityKey: string,
meta: WorktreeMeta,
current: { identityKey: string; meta: WorktreeMeta }
): boolean {
const touch = meta.lastActivityAt ?? 0
const currentTouch = current.meta.lastActivityAt ?? 0
return touch !== currentTouch ? touch > currentTouch : identityKey > current.identityKey
}
@@ -1,7 +1,7 @@
import type { WorkspaceAttachmentMutation } from '../../../shared/workspace-attachment-mutation'
import { randomUUID } from 'node:crypto'
import type { WorktreeMeta } from '../../../shared/worktree/meta-types'
import type { ExecutionHostId } from '../../../shared/execution-host'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host'
import { canonicalWorktreeIdentity } from '../../../shared/worktree/identity'
import {
composeWorktreeHostIdentity,
@@ -13,6 +13,10 @@ import type { StoreRuntimeState } from './store-runtime-state'
import type { WriteSchedulingOperations } from './write-scheduling'
import { scheduleSave } from './write-scheduling'
import { mergeWorktreeMetaForWrite } from './worktree-meta-write-normalization'
import {
isLocalOnlyWorktreeMetaId,
worktreeMetaHostFor
} from '../../../shared/worktree/local-only-worktree-meta-ids'
type MetadataRuntime = Pick<StoreRuntimeState, 'state'>
type WorktreeMetaState = Pick<
@@ -28,7 +32,10 @@ export const WORKTREE_METADATA_DOMAINS = [
] as const
/** Select one readable row without discarding competing alias candidates. */
function resolveAliasIdentityKey(state: WorktreeMetaState, alias: string): string | undefined {
export function resolveAliasIdentityKey(
state: WorktreeMetaState,
alias: string
): string | undefined {
const identityKeys = state.worktreeIdentityAliases?.[alias] ?? []
if (identityKeys.length === 0) {
return undefined
@@ -166,9 +173,10 @@ export function getWorktreeMetaForHost(
runtime: MetadataRuntime,
scheduling: WriteSchedulingOperations,
worktreeId: string,
executionHostId: ExecutionHostId
requestedHostId: ExecutionHostId
): WorktreeMeta | undefined {
const state = runtime.state
const executionHostId = worktreeMetaHostFor(worktreeId, requestedHostId)
const changed = migrateLegacyWorktreeMetadata(state, worktreeId, executionHostId)
const alias = composeWorktreeHostIdentity(executionHostId, worktreeId)
const identityKey = resolveAliasIdentityKey(state, alias)
@@ -182,6 +190,11 @@ export function getWorktreeMetaForHost(
return !legacy?.hostId || legacy.hostId === executionHostId ? legacy : undefined
}
// Why: an unstamped repo row may be any host's, but a local-only id is never a remote host's (#31498).
function isOtherHostsLocalOnlyRow(worktreeId: string, executionHostId: ExecutionHostId): boolean {
return executionHostId !== LOCAL_EXECUTION_HOST_ID && isLocalOnlyWorktreeMetaId(worktreeId)
}
/** Read-only host projection used by listings that must include canonical-only rows. */
export function getAllWorktreeMetaForHost(
runtime: { state: WorktreeMetaState },
@@ -190,6 +203,9 @@ export function getAllWorktreeMetaForHost(
const state = runtime.state
const projected: Record<string, WorktreeMeta> = {}
for (const [worktreeId, meta] of Object.entries(state.worktreeMeta)) {
if (isOtherHostsLocalOnlyRow(worktreeId, executionHostId)) {
continue
}
if (!meta.hostId || meta.hostId === executionHostId) {
projected[worktreeId] = meta
}
@@ -201,7 +217,12 @@ export function getAllWorktreeMetaForHost(
const worktreeId = getWorktreeIdFromHostIdentity(alias)
const identityKey = resolveAliasIdentityKey(state, alias)
const meta = identityKey ? state.worktreeMetaByIdentity?.[identityKey] : undefined
if (!worktreeId || !meta || (meta.hostId && meta.hostId !== executionHostId)) {
if (
!worktreeId ||
isOtherHostsLocalOnlyRow(worktreeId, executionHostId) ||
!meta ||
(meta.hostId && meta.hostId !== executionHostId)
) {
continue
}
projected[worktreeId] =
@@ -214,10 +235,11 @@ export function setWorktreeMetaForHost(
runtime: MetadataRuntime,
scheduling: WriteSchedulingOperations,
worktreeId: string,
executionHostId: ExecutionHostId,
requestedHostId: ExecutionHostId,
meta: Partial<WorktreeMeta> & WorkspaceAttachmentMutation
): WorktreeMeta {
const state = runtime.state
const executionHostId = worktreeMetaHostFor(worktreeId, requestedHostId)
migrateLegacyWorktreeMetadata(state, worktreeId, executionHostId)
const alias = composeWorktreeHostIdentity(executionHostId, worktreeId)
const identityKeys = state.worktreeIdentityAliases?.[alias] ?? []
@@ -22,6 +22,7 @@ import {
import type { WorktreeMeta } from '../../../shared/worktree/meta-types'
import { fillDefaultWorktreeMetaFields } from '../../../shared/worktree/meta-persisted-defaults'
import { getWorkspaceAttachments } from '../../../shared/workspace-attachments'
import { rehomeLocalOnlyWorktreeMetadata } from '../loading-store/local-only-worktree-meta-rehoming'
// Why: worktrees deleted outside Orca orphan their worktreeMeta, so the map grew monotonically (63% dead on a heavy install).
// GC stays narrow: local-host entries only (a local existsSync would falsely condemn SSH/WSL remote paths) and only after a 30-day idle grace.
@@ -217,6 +218,7 @@ export function normalizeWorktreeLinkedItemMetadata(state: PersistedState): bool
changed = true
}
}
changed = rehomeLocalOnlyWorktreeMetadata(state) || changed
if (aliasesCanProveReachability) {
changed = pruneUnreferencedWorktreeIdentityMeta(state) || changed
}
+18
View File
@@ -177,6 +177,24 @@ describe('independent runtime SSH access coordinator', () => {
expect(mocks.preflight).toHaveBeenCalledWith('target')
})
it('names the saved dependency when dependent state blocks an SSH access link', async () => {
mocks.preflight.mockReturnValue({
blockers: [
{
code: 'orcad_migration_dependent_state',
category: 'client-owned-state',
dependencies: [{ kind: 'worktree-metadata', count: 1, names: ['repo-1::/srv/app'] }]
}
]
})
await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow(
/orcad_migration_dependent_state\. [\s\S]*worktree-metadata ×1 \(repo-1::\/srv\/app\)/
)
expect(mocks.connect).not.toHaveBeenCalled()
expect(mocks.flush).not.toHaveBeenCalled()
expect(events).not.toContain('claim')
})
it('cancels a pending connect and starts no tunnel when the link is aborted', async () => {
const controller = new AbortController()
mocks.connect.mockImplementationOnce(
+26 -1
View File
@@ -25,6 +25,9 @@ import {
} from '../managed-server/lifecycle/orcad-managed-tunnel'
import { verifyRuntimeEnvironmentSshTunnel } from './runtime-ssh-access-verification'
import { fingerprintRuntimeSshTarget } from './runtime-ssh-target-fingerprint'
import { orcadTargetBlockerMessage } from './targets/ssh-target-orcad-claims'
import { dependentStateSummary } from './targets/ssh-target-orcad-dependents'
import type { OrcadMigrationBlocker } from '../../shared/orcad-migration/orcad-migration-preflight'
type TargetClaims = ReturnType<typeof requireManagedOrcadInfrastructure>['claims']
@@ -40,6 +43,26 @@ function requireTarget(claims: TargetClaims, targetId: string): SshTarget & { ge
return { ...target, generation }
}
// Why: the code alone gave users nothing to act on, and the host-conversion copy does not fit a link.
function sshAccessBlockerMessage(targetId: string, blocker: OrcadMigrationBlocker): string {
switch (blocker.code) {
case 'orcad_migration_dependent_state':
return `This SSH target is still used by saved Orca state: ${dependentStateSummary(blocker.dependencies)}. Remove it, or link SSH access through an SSH target with nothing saved.`
case 'orcad_migration_direct_ssh_repositories':
case 'orcad_migration_direct_ssh_folder_workspaces':
return 'This SSH target already has direct SSH projects. Remove them, or link SSH access through another SSH target.'
case 'orcad_migration_direct_ssh_terminal_leases':
return 'This SSH target still owns terminal sessions. Close them before linking SSH access.'
case 'orcad_migration_saved_port_forwards':
return 'This SSH target has saved port forwards. Remove them, or link SSH access through another SSH target.'
case 'orcad_migration_target_not_found':
case 'orcad_migration_target_owned':
case 'orcad_migration_owner_unrecorded':
case 'orcad_migration_dependency_unverifiable':
return orcadTargetBlockerMessage(targetId, blocker)
}
}
function requireAccessOnlyTarget(claims: TargetClaims, targetId: string, environmentId: string) {
const target = requireTarget(claims, targetId)
// Omitting the environment bypasses the same-owner shortcut and rechecks direct authority.
@@ -53,7 +76,9 @@ function requireAccessOnlyTarget(claims: TargetClaims, targetId: string, environ
)
)
if (blockers.length) {
throw new Error(`SSH access cannot claim direct SSH authority: ${blockers[0].code}`)
throw new Error(
`SSH access cannot claim direct SSH authority: ${blockers[0].code}. ${sshAccessBlockerMessage(targetId, blockers[0])}`
)
}
return target
}
@@ -7,6 +7,8 @@ export function emptyDependentStateStore(
): DependentStateStore {
return {
getAllWorktreeMetaForHost: () => ({}),
getProjects: () => [],
getRepos: () => [],
getSshRemotePtyLeases: () => [],
getWorkspaceSession: () => getDefaultWorkspaceSession(),
getWorkspaceSessionHostIds: () => ['local'],
@@ -1,5 +1,12 @@
import { describe, expect, it } from 'vitest'
import { getDefaultWorkspaceSession } from '../../../shared/constants'
import {
FLOATING_TERMINAL_WORKTREE_ID,
getDefaultWorkspaceSession
} from '../../../shared/constants'
import type { PersistedState } from '../../../shared/persisted-state-types'
import type { Repo } from '../../../shared/repo-types'
import { getAllWorktreeMetaForHost } from '../../persistence/loading-store/worktree-identity-metadata'
import { normalizeWorktreeLinkedItemMetadata } from '../../persistence/tracking-repos/worktree-metadata-normalization'
import type { ExecutionHostId } from '../../../shared/execution-host'
import { collectDependentStateBlockers } from './ssh-target-orcad-dependents'
import { emptyDependentStateStore } from './ssh-target-orcad-dependents-fixture'
@@ -103,8 +110,8 @@ describe('client state that still references an SSH target', () => {
})
it('blocks on worktree metadata recorded for the host', () => {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census reads only the keys.
const meta = { 'repo-1::/srv/app': {} } as never
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census reads the keys and host stamps.
const meta = { 'repo-1::/srv/app': { hostId: HOST } } as never
expect(
messageFor({
getAllWorktreeMetaForHost: (hostId) => (hostId === HOST ? meta : {})
@@ -112,6 +119,73 @@ describe('client state that still references an SSH target', () => {
).toContain('worktree-metadata ×1 (repo-1::/srv/app)')
})
describe('profiles with unstamped metadata rows from an older build (#31498)', () => {
const SETUP_TERMINAL_ID = 'ephemeral-setup-terminal:panel-1'
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census reads only id, connectionId and executionHostId.
const repos = [
{ id: 'local-repo', connectionId: null },
{ id: 'ssh-repo', connectionId: 'ssh-1' }
] as Repo[]
function legacyProfile(): PersistedState {
const row = { displayName: '', comment: '', isUnread: false, sortOrder: 1, lastActivityAt: 1 }
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the load normalizer and host projection read only these maps.
return {
repos,
worktreeMeta: {
[FLOATING_TERMINAL_WORKTREE_ID]: {
...row,
instanceId: '11111111-1111-4111-8111-111111111111',
workspaceStatus: 'in-progress'
},
[SETUP_TERMINAL_ID]: { ...row },
'local-repo::/home/me/app': { ...row },
'removed-repo::/srv/old': { ...row },
'project-1::/srv/project': { ...row },
'ssh-repo::/srv/app': { ...row }
},
worktreeMetaByIdentity: {},
worktreeIdentityAliases: {},
worktreeLineageById: {},
workspaceLineageByChildKey: {}
} as unknown as PersistedState
}
function metadataBlockerNames(state: PersistedState): string[] | undefined {
const [blocker] = blockersFor({
getRepos: () => repos,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census reads only project ids.
getProjects: () => [{ id: 'project-1' }] as never,
getAllWorktreeMetaForHost: (hostId) => getAllWorktreeMetaForHost({ state }, hostId)
})
return blocker?.code === 'orcad_migration_dependent_state'
? blocker.dependencies.flatMap((dependency) => dependency.names ?? [])
: undefined
}
it('counts only rows its repo or a project may own, before and after the load stamps them', () => {
const state = legacyProfile()
const owned = ['project-1::/srv/project', 'ssh-repo::/srv/app']
expect(metadataBlockerNames(state)).toEqual(owned)
expect(normalizeWorktreeLinkedItemMetadata(state)).toBe(true)
expect(state.worktreeMeta[FLOATING_TERMINAL_WORKTREE_ID]?.hostId).toBe('local')
expect(state.worktreeMeta[SETUP_TERMINAL_ID]?.hostId).toBe('local')
expect(state.worktreeMeta['ssh-repo::/srv/app']?.hostId).toBeUndefined()
expect(metadataBlockerNames(state)).toEqual(owned)
})
it('blocks an unrelated SSH target on neither the floating terminal row nor an orphan', () => {
const state = legacyProfile()
delete state.worktreeMeta['ssh-repo::/srv/app']
delete state.worktreeMeta['project-1::/srv/project']
expect(metadataBlockerNames(state)).toBeUndefined()
expect(getAllWorktreeMetaForHost({ state }, 'local')).toHaveProperty(
FLOATING_TERMINAL_WORKTREE_ID
)
})
})
it('blocks on a saved terminal lease of any status', () => {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census reads only ptyId and state.
const leases = [{ ptyId: 'pty-9', state: 'terminated' }] as never
@@ -4,7 +4,9 @@
*/
import type { Store } from '../../persistence'
import { getDefaultWorkspaceSession } from '../../../shared/constants'
import { toSshExecutionHostId } from '../../../shared/execution-host'
import { toSshExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host'
import { getRepoRegistrationHostIds } from '../../../shared/repo-host-identity'
import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id'
import { GLOBAL_WORKSPACE_SESSION_FIELDS } from '../../../shared/workspace-session-host-field-ownership'
import {
ORCAD_MIGRATION_DEPENDENCY_KINDS,
@@ -17,6 +19,8 @@ import type { OrcadMigrationManifest } from '../../../shared/orcad-migration/orc
export type DependentStateStore = Pick<
Store,
| 'getAllWorktreeMetaForHost'
| 'getProjects'
| 'getRepos'
| 'getSshRemotePtyLeases'
| 'getWorkspaceSession'
| 'getWorkspaceSessionHostIds'
@@ -40,7 +44,7 @@ export function collectDependentStateBlockers(
.filter((a) => a.executionTargetType === 'ssh' && a.executionTargetId === targetId)
.map((a) => a.name)
],
['worktree-metadata', () => Object.keys(store.getAllWorktreeMetaForHost(hostId))],
['worktree-metadata', () => hostWorktreeMetadataNames(store, hostId)],
// Why every status: a terminated lease is still a saved record pointing at this host.
[
'terminal-lease',
@@ -115,6 +119,25 @@ export function collectUntransferredDependentBlockers(
: []
}
/**
* The projection hands every host each unstamped legacy row, so one counts only when its repo is
* registered on this host, or it belongs to a project (which can span hosts). An orphan whose repo
* is gone is no host's state and cannot be cleared in the UI, so it must not block (#31498).
*/
function hostWorktreeMetadataNames(store: DependentStateStore, hostId: ExecutionHostId): string[] {
const repos = store.getRepos()
const projectIds = new Set(store.getProjects().map((project) => project.id))
return Object.entries(store.getAllWorktreeMetaForHost(hostId))
.filter(([worktreeId, meta]) => {
if (meta.hostId) {
return meta.hostId === hostId
}
const ownerId = getRepoIdFromWorktreeId(worktreeId)
return getRepoRegistrationHostIds(repos, ownerId).has(hostId) || projectIds.has(ownerId)
})
.map(([worktreeId]) => worktreeId)
}
/** Non-default host-owned fields of the host's partition, plus a local session pointed at the host. */
function workspaceSessionReferences(store: DependentStateStore, hostId: string): string[] {
const references: string[] = []
@@ -148,12 +171,19 @@ function isEmptyValue(value: unknown): boolean {
return typeof value === 'object' && Object.keys(value).length === 0
}
/** Each blocking dependency with its count and the first names, e.g. `automation ×1 (Nightly)`. */
export function dependentStateSummary(dependencies: OrcadMigrationDependency[]): string {
return dependencies
.map(
({ kind, count, names }) =>
`${kind} ×${count}${names?.length ? ` (${names.join(', ')})` : ''}`
)
.join('; ')
}
export function dependentStateMessage(dependencies: OrcadMigrationDependency[]): string {
const parts = dependencies.map(
({ kind, count, names }) => `${kind} ×${count}${names?.length ? ` (${names.join(', ')})` : ''}`
)
return (
'This SSH target is still referenced by saved Orca state that this host’s Orca server cannot take ' +
`over yet: ${parts.join('; ')}. Remove it, or keep this host in direct SSH mode.`
`over yet: ${dependentStateSummary(dependencies)}. Remove it, or keep this host in direct SSH mode.`
)
}
@@ -0,0 +1,19 @@
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../execution-host'
import { isEphemeralSetupTerminalWorktreeId } from '../ephemeral-setup-terminal-worktree-id'
import { isFloatingWorkspaceId } from '../floating-workspace-worktree'
/**
* Workspace ids with no repo or folder row behind them. Each store keeps their metadata for its
* own machine, so they are never another host's state (#31498).
*/
export function isLocalOnlyWorktreeMetaId(worktreeId: string): boolean {
return isFloatingWorkspaceId(worktreeId) || isEphemeralSetupTerminalWorktreeId(worktreeId)
}
/** The host a metadata row for `worktreeId` belongs to when a caller names `executionHostId`. */
export function worktreeMetaHostFor(
worktreeId: string,
executionHostId: ExecutionHostId
): ExecutionHostId {
return isLocalOnlyWorktreeMetaId(worktreeId) ? LOCAL_EXECUTION_HOST_ID : executionHostId
}