From 92c8f3cbdc186bcd0ac4a8e7a1c969dadece2f38 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:39:54 -0700 Subject: [PATCH] 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 * 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 --- .../orcad-runtime-deployment.test.ts | 4 +- ...rsistence-local-only-worktree-meta.test.ts | 145 ++++++++++++++++++ .../host-qualified-worktree-meta.ts | 11 +- .../local-only-worktree-meta-rehoming.ts | 108 +++++++++++++ .../worktree-identity-metadata.ts | 32 +++- .../worktree-metadata-normalization.ts | 2 + src/main/ssh/runtime-ssh-access.test.ts | 18 +++ src/main/ssh/runtime-ssh-access.ts | 27 +++- .../ssh-target-orcad-dependents-fixture.ts | 2 + .../ssh-target-orcad-dependents.test.ts | 80 +++++++++- .../targets/ssh-target-orcad-dependents.ts | 42 ++++- .../worktree/local-only-worktree-meta-ids.ts | 19 +++ 12 files changed, 472 insertions(+), 18 deletions(-) create mode 100644 src/main/persistence-local-only-worktree-meta.test.ts create mode 100644 src/main/persistence/loading-store/local-only-worktree-meta-rehoming.ts create mode 100644 src/shared/worktree/local-only-worktree-meta-ids.ts diff --git a/src/main/managed-server/lifecycle/orcad-runtime-deployment.test.ts b/src/main/managed-server/lifecycle/orcad-runtime-deployment.test.ts index 07aa91dce4b..c67b0c8fb02 100644 --- a/src/main/managed-server/lifecycle/orcad-runtime-deployment.test.ts +++ b/src/main/managed-server/lifecycle/orcad-runtime-deployment.test.ts @@ -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) => { @@ -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 }) diff --git a/src/main/persistence-local-only-worktree-meta.test.ts b/src/main/persistence-local-only-worktree-meta.test.ts new file mode 100644 index 00000000000..810648ad32f --- /dev/null +++ b/src/main/persistence-local-only-worktree-meta.test.ts @@ -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 = {}) { + 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([]) + }) +}) diff --git a/src/main/persistence/host-qualified-worktree-meta.ts b/src/main/persistence/host-qualified-worktree-meta.ts index ecf4fa51d51..b65f9e699f8 100644 --- a/src/main/persistence/host-qualified-worktree-meta.ts +++ b/src/main/persistence/host-qualified-worktree-meta.ts @@ -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. diff --git a/src/main/persistence/loading-store/local-only-worktree-meta-rehoming.ts b/src/main/persistence/loading-store/local-only-worktree-meta-rehoming.ts new file mode 100644 index 00000000000..60b9a7fcf5d --- /dev/null +++ b/src/main/persistence/loading-store/local-only-worktree-meta-rehoming.ts @@ -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() + const newestRemote = new Map() + 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 +} diff --git a/src/main/persistence/loading-store/worktree-identity-metadata.ts b/src/main/persistence/loading-store/worktree-identity-metadata.ts index 461afbd5b87..d9547454b64 100644 --- a/src/main/persistence/loading-store/worktree-identity-metadata.ts +++ b/src/main/persistence/loading-store/worktree-identity-metadata.ts @@ -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 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 = {} 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 & 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] ?? [] diff --git a/src/main/persistence/tracking-repos/worktree-metadata-normalization.ts b/src/main/persistence/tracking-repos/worktree-metadata-normalization.ts index 34d47eb208e..2771b18d26a 100644 --- a/src/main/persistence/tracking-repos/worktree-metadata-normalization.ts +++ b/src/main/persistence/tracking-repos/worktree-metadata-normalization.ts @@ -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 } diff --git a/src/main/ssh/runtime-ssh-access.test.ts b/src/main/ssh/runtime-ssh-access.test.ts index d3e5e212d11..8e9b3916ee9 100644 --- a/src/main/ssh/runtime-ssh-access.test.ts +++ b/src/main/ssh/runtime-ssh-access.test.ts @@ -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( diff --git a/src/main/ssh/runtime-ssh-access.ts b/src/main/ssh/runtime-ssh-access.ts index 8e609fd6ff7..efa513da027 100644 --- a/src/main/ssh/runtime-ssh-access.ts +++ b/src/main/ssh/runtime-ssh-access.ts @@ -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['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 } diff --git a/src/main/ssh/targets/ssh-target-orcad-dependents-fixture.ts b/src/main/ssh/targets/ssh-target-orcad-dependents-fixture.ts index 2fd62577b58..e06cc100ba1 100644 --- a/src/main/ssh/targets/ssh-target-orcad-dependents-fixture.ts +++ b/src/main/ssh/targets/ssh-target-orcad-dependents-fixture.ts @@ -7,6 +7,8 @@ export function emptyDependentStateStore( ): DependentStateStore { return { getAllWorktreeMetaForHost: () => ({}), + getProjects: () => [], + getRepos: () => [], getSshRemotePtyLeases: () => [], getWorkspaceSession: () => getDefaultWorkspaceSession(), getWorkspaceSessionHostIds: () => ['local'], diff --git a/src/main/ssh/targets/ssh-target-orcad-dependents.test.ts b/src/main/ssh/targets/ssh-target-orcad-dependents.test.ts index c6e77b4322b..a17624c4867 100644 --- a/src/main/ssh/targets/ssh-target-orcad-dependents.test.ts +++ b/src/main/ssh/targets/ssh-target-orcad-dependents.test.ts @@ -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 diff --git a/src/main/ssh/targets/ssh-target-orcad-dependents.ts b/src/main/ssh/targets/ssh-target-orcad-dependents.ts index 28b53e830b8..d1b0721c7e6 100644 --- a/src/main/ssh/targets/ssh-target-orcad-dependents.ts +++ b/src/main/ssh/targets/ssh-target-orcad-dependents.ts @@ -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.` ) } diff --git a/src/shared/worktree/local-only-worktree-meta-ids.ts b/src/shared/worktree/local-only-worktree-meta-ids.ts new file mode 100644 index 00000000000..6c4379472cf --- /dev/null +++ b/src/shared/worktree/local-only-worktree-meta-ids.ts @@ -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 +}