mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user