From 7b5216328a7287cfd5b1093f97841a3ef2704a1a Mon Sep 17 00:00:00 2001 From: m4air Date: Fri, 2 Oct 2026 00:10:02 -0700 Subject: [PATCH 1/3] feat(ssh): retire a migrated SSH host's source state only after a proven commit (#16741 T8-c5) Once the journal records destination-committed, the source profile drops the manifest's repositories, folder workspaces and unreferenced project groups, its dormant session, automation, client and worktree state, and the leases the fence proved exited. The profile flushes, the retirement is verified, the journal moves to source-retired and compacts once the server matches. A retry after any crash repeats idempotent work. The fenced target stays: it carries the managed server's tunnel. Conversion now ends retired. Inert until T6-10. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../loading-store/store-domain-composition.ts | 9 + .../orcad-source-automation-retirement.ts | 35 +++ .../orcad-source-catalog-retirement.ts | 55 ++++ .../orcad-source-client-retirement.ts | 261 ++++++++++++++++++ .../orcad-source-dormant-retirement.ts | 83 ++++++ .../orcad-source-retirement.ts | 73 +++++ ...cad-source-workspace-session-retirement.ts | 100 +++++++ .../orcad-migration-source-retirement.test.ts | 143 ++++++++++ .../ssh/orcad-migration-source-retirement.ts | 89 ++++++ src/main/ssh/orcad-runtime-conversion.test.ts | 26 +- src/main/ssh/orcad-runtime-conversion.ts | 26 +- src/shared/orcad-managed-runtime.ts | 3 +- 12 files changed, 890 insertions(+), 13 deletions(-) create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-automation-retirement.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-catalog-retirement.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-client-retirement.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-dormant-retirement.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-retirement.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-retirement.ts create mode 100644 src/main/ssh/orcad-migration-source-retirement.test.ts create mode 100644 src/main/ssh/orcad-migration-source-retirement.ts diff --git a/src/main/persistence/loading-store/store-domain-composition.ts b/src/main/persistence/loading-store/store-domain-composition.ts index 4ba2e58881ac..103bc4132187 100644 --- a/src/main/persistence/loading-store/store-domain-composition.ts +++ b/src/main/persistence/loading-store/store-domain-composition.ts @@ -67,6 +67,10 @@ import { OrcadSourceExportPersistence, installOrcadSourceExportPersistenceContext } from '../migrating-orcad-catalog/orcad-source-export' +import { + OrcadSourceRetirementPersistence, + installOrcadSourceRetirementPersistenceContext +} from '../migrating-orcad-catalog/orcad-source-retirement' import { OrcadCatalogImportPersistence, installOrcadCatalogImportPersistenceContext @@ -88,6 +92,7 @@ export type StoreDomainOperations = WriteSchedulingOperations & RetiredWorktreeNamePersistence & SshLeaseRecoveryOperations & OrcadSourceExportPersistence & + OrcadSourceRetirementPersistence & OrcadCatalogImportPersistence & WriteFlushBarrierOperations @@ -114,6 +119,7 @@ export type StoreDomains = { retiredWorktreeNames: RetiredWorktreeNamePersistence sshLeases: SshLeaseRecoveryOperations orcadSourceExport: OrcadSourceExportPersistence + orcadSourceRetirement: OrcadSourceRetirementPersistence orcadCatalogImports: OrcadCatalogImportPersistence } @@ -134,6 +140,7 @@ export const STORE_DOMAIN_OPERATION_CLASSES = [ RetiredWorktreeNamePersistence, SshLeaseRecoveryOperations, OrcadSourceExportPersistence, + OrcadSourceRetirementPersistence, OrcadCatalogImportPersistence, WriteFlushBarrierOperations ] as const @@ -155,6 +162,7 @@ export function installStoreDomainContexts(target: Store, domains: StoreDomains) installRetiredWorktreeNamePersistenceContext(target, domains.retiredWorktreeNames) installSshLeaseRecoveryOperationsContext(target, domains.sshLeases) installOrcadSourceExportPersistenceContext(target, domains.orcadSourceExport) + installOrcadSourceRetirementPersistenceContext(target, domains.orcadSourceRetirement) installOrcadCatalogImportPersistenceContext(target, domains.orcadCatalogImports) installWriteFlushBarrierOperationsContext(target, domains.flushBarriers) } @@ -216,6 +224,7 @@ export function createStoreDomains(runtime: StoreRuntimeState): StoreDomains { sshLeases, // Read-only: holds the runtime state and nothing that writes. orcadSourceExport: new OrcadSourceExportPersistence(runtime), + orcadSourceRetirement: new OrcadSourceRetirementPersistence(runtime, repos, scheduling), orcadCatalogImports } } diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-automation-retirement.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-automation-retirement.ts new file mode 100644 index 000000000000..e2087993e845 --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-automation-retirement.ts @@ -0,0 +1,35 @@ +import type { OrcadMigrationManifest } from '../../../shared/orcad-migration-manifest' +import type { PersistedState } from '../../../shared/persisted-state-types' + +export function retireOrcadMigrationSourceAutomationState( + state: PersistedState, + manifest: OrcadMigrationManifest +): void { + const automations = manifest.payload.dormantState?.automations ?? [] + const automationRuns = manifest.payload.dormantState?.automationRuns ?? [] + if (automations.length === 0 && automationRuns.length === 0) { + return + } + const automationIds = new Set(automations.map((entry) => entry.id)) + const runIds = new Set(automationRuns.map((entry) => entry.id)) + state.automations = state.automations.filter((entry) => !automationIds.has(entry.id)) + state.automationRuns = state.automationRuns.filter((entry) => !runIds.has(entry.id)) +} + +export function assertOrcadMigrationSourceAutomationStateRetired( + state: PersistedState, + manifest: OrcadMigrationManifest +): void { + const automationIds = new Set( + (manifest.payload.dormantState?.automations ?? []).map((entry) => entry.id) + ) + const runIds = new Set( + (manifest.payload.dormantState?.automationRuns ?? []).map((entry) => entry.id) + ) + if ( + state.automations.some((entry) => automationIds.has(entry.id)) || + state.automationRuns.some((entry) => runIds.has(entry.id)) + ) { + throw new Error('orcad_migration_source_automation_state_reappeared') + } +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-catalog-retirement.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-catalog-retirement.ts new file mode 100644 index 000000000000..27bd170d8e47 --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-catalog-retirement.ts @@ -0,0 +1,55 @@ +/** + * Removes a migrated target's catalog rows from the source profile. Only rows the manifest names + * and the target still owns go; a group stays while anything outside the migration references it. + */ +import type { StoreRuntimeState } from '../loading-store/store-runtime-state' +import type { OrcadMigrationManifest } from '../../../shared/orcad-migration-manifest' + +export function retireOrcadSourceCatalogState( + state: StoreRuntimeState['state'], + manifest: OrcadMigrationManifest +): void { + const repoIds = new Set(manifest.payload.repositories.map((repo) => repo.id)) + const folderIds = new Set(manifest.payload.folderWorkspaces.map((workspace) => workspace.id)) + const groupIds = new Set(manifest.payload.projectGroups.map((group) => group.id)) + const groupConnectionById = new Map( + state.projectGroups.map((group) => [group.id, group.connectionId]) + ) + state.repos = state.repos.filter( + (repo) => !(repoIds.has(repo.id) && repo.connectionId === manifest.source.sshTargetId) + ) + state.folderWorkspaces = state.folderWorkspaces.filter( + (workspace) => + !( + folderIds.has(workspace.id) && + (workspace.connectionId ?? groupConnectionById.get(workspace.projectGroupId) ?? null) === + manifest.source.sshTargetId + ) + ) + removeUnreferencedSourceGroups(state, groupIds, manifest.source.sshTargetId) +} + +function removeUnreferencedSourceGroups( + state: StoreRuntimeState['state'], + candidateIds: ReadonlySet, + targetId: string +): void { + let removed = true + while (removed) { + removed = false + const referencedIds = new Set([ + ...state.repos.flatMap((repo) => (repo.projectGroupId ? [repo.projectGroupId] : [])), + ...state.folderWorkspaces.map((workspace) => workspace.projectGroupId), + ...state.projectGroups.flatMap((group) => (group.parentGroupId ? [group.parentGroupId] : [])) + ]) + const next = state.projectGroups.filter((group) => { + const shouldRemove = + candidateIds.has(group.id) && + group.connectionId === targetId && + !referencedIds.has(group.id) + removed ||= shouldRemove + return !shouldRemove + }) + state.projectGroups = next + } +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-client-retirement.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-client-retirement.ts new file mode 100644 index 000000000000..cc734dc1c2db --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-client-retirement.ts @@ -0,0 +1,261 @@ +import type { OrcadMigrationManifest } from '../../../shared/orcad-migration-manifest' +import { serializeOrcadMigrationValue } from '../../../shared/orcad-migration-manifest' +import { parsePersistedAutomationHostFilter } from '../../../shared/automation-host-filter' +import { hostStableKey } from '../../../shared/automation-owner-key' +import { toRuntimeExecutionHostId } from '../../../shared/execution-host' +import { composeWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity' +import type { OrcadMigrationClientStatePayload } from '../../../shared/orcad-migration-client-state' +import type { PersistedState } from '../../../shared/persisted-state-types' +import { + MAX_CLIENT_HOSTED_BROWSER_CLOSE_INTENTS, + type ClientHostedBrowserCloseIntent +} from '../../../shared/client-hosted-browser-close-intent' +import { + createOrcadMigrationSourceScope, + orcadMigrationOwnerMatchesScope, + unqualifyOrcadMigrationOwnerKey +} from './orcad-source-scope' + +export function assertOrcadMigrationClientStateRetired( + state: PersistedState, + manifest: OrcadMigrationManifest +): void { + const clientState = manifest.payload.dormantState?.clientState + if (!clientState) { + return + } + const source = manifest.source + const scope = createOrcadMigrationSourceScope({ source, catalog: manifest.payload }) + for (const [deviceId, selections] of Object.entries( + clientState.mobileClientTabSelectionsByDeviceId ?? {} + )) { + for (const ownerKey of Object.keys(selections)) { + if (state.mobileClientTabSelectionsByDeviceId?.[deviceId]?.[ownerKey] !== undefined) { + throw new Error('orcad_migration_source_mobile_selection_reappeared') + } + } + } + const ui = state.ui + if ( + clientState.uiRouting?.lastActiveWorktreeId && + orcadMigrationOwnerMatchesScope(ui.lastActiveWorktreeId, scope) + ) { + throw new Error('orcad_migration_source_ui_routing_reappeared') + } + const target = state.sshTargets.find((entry) => entry.id === source.sshTargetId) + if ( + clientState.savedPortForwards && + (!target || + serializeOrcadMigrationValue(target.portForwards ?? []) !== + serializeOrcadMigrationValue(clientState.savedPortForwards)) + ) { + throw new Error('orcad_migration_source_saved_port_forwards_changed') + } + for (const intent of clientState.clientHostedBrowserCloseIntents ?? []) { + const sourceIntents = + state.workspaceSession.clientHostedBrowserCloseIntentsByEnvironment?.[ + intent.sourceEnvironmentId + ] + if ( + sourceIntents?.some( + (entry) => + entry.browserPageId === intent.browserPageId && entry.worktreeId === intent.worktreeId + ) + ) { + throw new Error('orcad_migration_source_close_intent_reappeared') + } + const destinationIntents = + state.workspaceSession.clientHostedBrowserCloseIntentsByEnvironment?.[ + manifest.destinationEnvironmentId ?? '' + ] + const transferred = destinationIntents?.find( + (entry) => + entry.browserPageId === intent.browserPageId && entry.worktreeId === intent.worktreeId + ) + if ( + !transferred || + serializeOrcadMigrationValue(transferred) !== + serializeOrcadMigrationValue({ + browserPageId: intent.browserPageId, + worktreeId: intent.worktreeId, + closedAt: intent.closedAt + }) + ) { + throw new Error('orcad_migration_source_close_intent_missing') + } + } +} + +export function retireOrcadMigrationClientState( + state: PersistedState, + manifest: OrcadMigrationManifest +): void { + const clientState = manifest.payload.dormantState?.clientState + if (!clientState) { + return + } + const source = manifest.source + const scope = createOrcadMigrationSourceScope({ source, catalog: manifest.payload }) + if (clientState.mobileClientTabSelectionsByDeviceId) { + for (const [deviceId, captured] of Object.entries( + clientState.mobileClientTabSelectionsByDeviceId + )) { + const selections = state.mobileClientTabSelectionsByDeviceId?.[deviceId] + if (!selections) { + continue + } + for (const ownerKey of Object.keys(captured)) { + delete selections[ownerKey] + } + if (Object.keys(selections).length === 0) { + delete state.mobileClientTabSelectionsByDeviceId?.[deviceId] + } + } + } + const target = state.sshTargets.find((entry) => entry.id === source.sshTargetId) + if (target && clientState.savedPortForwards) { + target.portForwards = structuredClone(clientState.savedPortForwards) + } + retireCloseIntents(state, clientState.clientHostedBrowserCloseIntents ?? [], manifest) + rewriteDesktopUiForDestination(state, manifest, scope) +} + +function retireCloseIntents( + state: PersistedState, + captured: readonly NonNullable< + OrcadMigrationClientStatePayload['clientHostedBrowserCloseIntents'] + >[number][], + manifest: OrcadMigrationManifest +): void { + if (captured.length === 0) { + return + } + const current = state.workspaceSession.clientHostedBrowserCloseIntentsByEnvironment ?? {} + const next: Record = Object.fromEntries( + Object.entries(current).map(([key, entries]) => [key, structuredClone(entries)]) + ) + const destinationEnvironmentId = manifest.destinationEnvironmentId + if (!destinationEnvironmentId) { + throw new Error('orcad_migration_source_close_intent_destination_invalid') + } + for (const intent of captured) { + const sourceEntries = next[intent.sourceEnvironmentId] ?? [] + const sourceIndex = sourceEntries.findIndex( + (entry) => + entry.browserPageId === intent.browserPageId && entry.worktreeId === intent.worktreeId + ) + if ( + sourceIndex === -1 || + serializeOrcadMigrationValue(sourceEntries[sourceIndex]) !== + serializeOrcadMigrationValue({ + browserPageId: intent.browserPageId, + worktreeId: intent.worktreeId, + closedAt: intent.closedAt + }) + ) { + throw new Error('orcad_migration_source_close_intent_changed') + } + sourceEntries.splice(sourceIndex, 1) + if (sourceEntries.length === 0) { + delete next[intent.sourceEnvironmentId] + } else { + next[intent.sourceEnvironmentId] = sourceEntries + } + const destinationEntries = next[destinationEnvironmentId] ?? [] + const existing = destinationEntries.find( + (entry) => + entry.browserPageId === intent.browserPageId && entry.worktreeId === intent.worktreeId + ) + if (existing) { + if ( + serializeOrcadMigrationValue(existing) !== + serializeOrcadMigrationValue({ + browserPageId: intent.browserPageId, + worktreeId: intent.worktreeId, + closedAt: intent.closedAt + }) + ) { + throw new Error('orcad_migration_close_intent_destination_conflict') + } + continue + } + if (destinationEntries.length >= MAX_CLIENT_HOSTED_BROWSER_CLOSE_INTENTS) { + throw new Error('orcad_migration_close_intent_destination_capacity_exceeded') + } + destinationEntries.push({ + browserPageId: intent.browserPageId, + worktreeId: intent.worktreeId, + closedAt: intent.closedAt + }) + next[destinationEnvironmentId] = destinationEntries + } + state.workspaceSession.clientHostedBrowserCloseIntentsByEnvironment = next +} + +function rewriteDesktopUiForDestination( + state: PersistedState, + manifest: OrcadMigrationManifest, + scope: ReturnType +): void { + const route = manifest.payload.dormantState?.clientState?.uiRouting + const destinationEnvironmentId = manifest.destinationEnvironmentId + if (!route || !destinationEnvironmentId) { + return + } + if ( + route.lastActiveWorktreeId && + orcadMigrationOwnerMatchesScope(state.ui.lastActiveWorktreeId, scope) + ) { + state.ui.lastActiveWorktreeId = composeWorktreeHostIdentity( + toRuntimeExecutionHostId(destinationEnvironmentId), + route.lastActiveWorktreeId + ) + } + if (route.workspaceHostScope === 'local' && state.ui.workspaceHostScope === scope.hostId) { + state.ui.workspaceHostScope = `runtime:${encodeURIComponent(destinationEnvironmentId)}` + } + if (route.visibleWorkspaceHostIds?.includes('local') && state.ui.visibleWorkspaceHostIds) { + state.ui.visibleWorkspaceHostIds = state.ui.visibleWorkspaceHostIds.map((hostId) => + hostId === scope.hostId + ? (`runtime:${encodeURIComponent(destinationEnvironmentId)}` as const) + : hostId + ) + } + if (route.workspaceHostOrder?.includes('local')) { + state.ui.workspaceHostOrder = (state.ui.workspaceHostOrder ?? []).map((hostId) => + hostId === scope.hostId + ? (`runtime:${encodeURIComponent(destinationEnvironmentId)}` as const) + : hostId + ) + } + if (route.manualRepoOrder) { + const repoIds = new Set(route.manualRepoOrder.map((entry) => entry.repoId)) + state.ui.manualRepoOrder = (state.ui.manualRepoOrder ?? []).map((entry) => + entry.hostId === scope.hostId && repoIds.has(entry.repoId) + ? { ...entry, hostId: `runtime:${encodeURIComponent(destinationEnvironmentId)}` as const } + : entry + ) + } + if (route.showDotfilesByWorktree) { + const next = { ...state.ui.showDotfilesByWorktree } + for (const [ownerKey, enabled] of Object.entries(next)) { + if (orcadMigrationOwnerMatchesScope(ownerKey, scope)) { + delete next[ownerKey] + next[ + `runtime:${encodeURIComponent(destinationEnvironmentId)}|${unqualifyOrcadMigrationOwnerKey(ownerKey)}` + ] = enabled + } + } + state.ui.showDotfilesByWorktree = next + } + if (route.automationHostFilter?.kind === 'host') { + const current = parsePersistedAutomationHostFilter(state.ui.automationHostFilter) + const sourceKey = hostStableKey({ + authority: { kind: 'desktop' }, + selector: { kind: 'ssh', targetId: scope.targetId } + }) + if (current.kind === 'host' && hostStableKey(current.host) === sourceKey) { + state.ui.automationHostFilter = structuredClone(route.automationHostFilter) + } + } +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-dormant-retirement.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-dormant-retirement.ts new file mode 100644 index 000000000000..d4ee3efcbcb9 --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-dormant-retirement.ts @@ -0,0 +1,83 @@ +import type { OrcadMigrationManifest } from '../../../shared/orcad-migration-manifest' +import type { PersistedState } from '../../../shared/persisted-state-types' +import { isEmptyRetiredNameRegistry } from '../../../shared/worktree/retired-name-registry' +import { + assertOrcadSourceWorktreeMetadataRetired, + retireOrcadSourceWorktreeMetadata +} from './orcad-source-worktree-metadata' +import { + assertOrcadMigrationSourceAutomationStateRetired, + retireOrcadMigrationSourceAutomationState +} from './orcad-source-automation-retirement' +import { + assertOrcadMigrationSourceWorkspaceSessionRetired, + retireOrcadMigrationSourceWorkspaceSession +} from './orcad-source-workspace-session-retirement' +import { + assertOrcadMigrationClientStateRetired, + retireOrcadMigrationClientState +} from './orcad-source-client-retirement' + +export function assertOrcadMigrationSourceDormantStateRetired( + state: PersistedState, + manifest: OrcadMigrationManifest +): void { + const dormant = manifest.payload.dormantState + if (!dormant) { + return + } + const hasRows = + dormant.worktreeMeta.some((entry) => state.worktreeMeta[entry.sourceKey] !== undefined) || + dormant.worktreeLineage.some( + (entry) => state.worktreeLineageById[entry.sourceKey] !== undefined + ) || + dormant.workspaceLineage.some( + (entry) => state.workspaceLineageByChildKey[entry.sourceKey] !== undefined + ) || + dormant.sparsePresets.some((preset) => + state.sparsePresetsByRepo[preset.repoId]?.some((entry) => entry.id === preset.id) + ) || + dormant.retiredWorktreeNames.some((entry) => { + const registry = state.retiredWorktreeNamesByRepo?.[entry.repoId] + return registry !== undefined && !isEmptyRetiredNameRegistry(registry) + }) + if (hasRows) { + throw new Error('orcad_migration_source_dormant_state_reappeared') + } + assertOrcadSourceWorktreeMetadataRetired(state, manifest) + assertOrcadMigrationSourceWorkspaceSessionRetired(state, manifest) + assertOrcadMigrationSourceAutomationStateRetired(state, manifest) + assertOrcadMigrationClientStateRetired(state, manifest) +} + +export function retireOrcadMigrationSourceDormantState( + state: PersistedState, + manifest: OrcadMigrationManifest +): void { + const dormant = manifest.payload.dormantState + if (!dormant) { + return + } + retireOrcadSourceWorktreeMetadata(state, manifest) + dormant.worktreeLineage.forEach((entry) => delete state.worktreeLineageById[entry.sourceKey]) + dormant.workspaceLineage.forEach( + (entry) => delete state.workspaceLineageByChildKey[entry.sourceKey] + ) + for (const preset of dormant.sparsePresets) { + const remaining = (state.sparsePresetsByRepo[preset.repoId] ?? []).filter( + (entry) => entry.id !== preset.id + ) + if (remaining.length > 0) { + state.sparsePresetsByRepo[preset.repoId] = remaining + } else { + delete state.sparsePresetsByRepo[preset.repoId] + } + } + for (const entry of dormant.retiredWorktreeNames) { + delete state.retiredWorktreeNamesByRepo?.[entry.repoId] + } + retireOrcadMigrationSourceWorkspaceSession(state, manifest) + retireOrcadMigrationSourceAutomationState(state, manifest) + retireOrcadMigrationClientState(state, manifest) + // Retain snapshot files: the prior durable profile and rollback evidence can still reference them. +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-retirement.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-retirement.ts new file mode 100644 index 000000000000..7b5d74750871 --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-retirement.ts @@ -0,0 +1,73 @@ +/** + * The Store's one write for a migrated target: retiring the source rows a destination proved it + * committed. Idempotent, so a retry after a crash before the journal moved finishes the same work. + */ +import type { OrcadMigrationManifest } from '../../../shared/orcad-migration-manifest' +import { assertOrcadMigrationManifestDigest } from '../../orcad/orcad-migration-manifest-digest' +import { + syncProjectHostSetupCompatibilityState, + type RepoLifecycleOperations +} from '../loading-store/repo-lifecycle-operations' +import type { StoreRuntimeState } from '../loading-store/store-runtime-state' +import { scheduleSave, type WriteSchedulingOperations } from '../loading-store/write-scheduling' +import { retireOrcadSourceCatalogState } from './orcad-source-catalog-retirement' +import { + assertOrcadMigrationSourceDormantStateRetired, + retireOrcadMigrationSourceDormantState +} from './orcad-source-dormant-retirement' + +const orcadSourceRetirementContext = Symbol('OrcadSourceRetirementPersistence') +type OrcadSourceRetirementContext = { + runtime: Pick + repos: RepoLifecycleOperations + scheduling: WriteSchedulingOperations +} + +export class OrcadSourceRetirementPersistence { + readonly [orcadSourceRetirementContext]: OrcadSourceRetirementContext + + constructor( + runtime: Pick, + repos: RepoLifecycleOperations, + scheduling: WriteSchedulingOperations + ) { + this[orcadSourceRetirementContext] = { runtime, repos, scheduling } + } + + /** The caller holds a journal that proves the destination committed this exact manifest. */ + retireOrcadMigrationSourceCatalog(manifest: OrcadMigrationManifest): void { + assertOrcadMigrationManifestDigest(manifest) + const context = this[orcadSourceRetirementContext] + const state = context.runtime.state + retireOrcadSourceCatalogState(state, manifest) + retireOrcadMigrationSourceDormantState(state, manifest) + syncProjectHostSetupCompatibilityState(context.repos) + scheduleSave(context.scheduling) + } + + /** Throws when any row the manifest moved is still, or again, on the source. */ + assertOrcadMigrationSourceRetired(manifest: OrcadMigrationManifest): void { + const state = this[orcadSourceRetirementContext].runtime.state + const target = manifest.source.sshTargetId + const repoIds = new Set(manifest.payload.repositories.map((repo) => repo.id)) + const folderIds = new Set(manifest.payload.folderWorkspaces.map((workspace) => workspace.id)) + if ( + state.repos.some((repo) => repoIds.has(repo.id) && repo.connectionId === target) || + state.folderWorkspaces.some( + (workspace) => folderIds.has(workspace.id) && workspace.connectionId === target + ) + ) { + throw new Error('orcad_migration_source_catalog_reappeared') + } + assertOrcadMigrationSourceDormantStateRetired(state, manifest) + } +} + +export function installOrcadSourceRetirementPersistenceContext( + target: OrcadSourceRetirementPersistence, + source: OrcadSourceRetirementPersistence +): void { + Object.defineProperty(target, orcadSourceRetirementContext, { + value: source[orcadSourceRetirementContext] + }) +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-retirement.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-retirement.ts new file mode 100644 index 000000000000..c13e783fbcfa --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-retirement.ts @@ -0,0 +1,100 @@ +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { markdownFileIdCandidates } from '../../orca-profiles/profile-session-markdown-transfer' +import { removeWorkspaceSessionOwners } from '../restoring-sessions/session-owner-removal' +import { + createOrcadMigrationSourceScope, + orcadMigrationOwnerMatchesScope, + type OrcadMigrationSourceScope +} from './orcad-source-scope' +import { collectSessionOwnerKeys } from './orcad-source-workspace-session-fragments' +import type { OrcadMigrationManifest } from '../../../shared/orcad-migration-manifest' +import type { PersistedState } from '../../../shared/persisted-state-types' +import { collectOrcadMigrationSourceWorkspaceSession } from './orcad-source-workspace-session' + +export function retireOrcadMigrationSourceWorkspaceSession( + state: PersistedState, + manifest: OrcadMigrationManifest +): void { + if (!manifest.payload.dormantState?.workspaceSession) { + return + } + const scope = createOrcadMigrationSourceScope({ + source: manifest.source, + catalog: manifest.payload + }) + state.workspaceSession = removeOwnedSessionState(state.workspaceSession, scope) + const partitions = state.workspaceSessionsByHostId + if (partitions) { + state.workspaceSessionsByHostId = Object.fromEntries( + Object.entries(partitions).map(([hostId, session]) => [ + hostId, + session ? removeOwnedSessionState(session, scope) : session + ]) + ) + } +} + +export function assertOrcadMigrationSourceWorkspaceSessionRetired( + state: PersistedState, + manifest: OrcadMigrationManifest +): void { + if (!manifest.payload.dormantState?.workspaceSession) { + return + } + const current = collectOrcadMigrationSourceWorkspaceSession( + state, + manifest.source, + manifest.payload + ) + if (current.payload || current.blockedCount > 0) { + throw new Error('orcad_migration_source_workspace_session_reappeared') + } +} + +export function removeOwnedSessionState( + session: WorkspaceSessionState, + scope: OrcadMigrationSourceScope +): WorkspaceSessionState { + const ownerKeys = new Set( + [...collectSessionOwnerKeys(session)].filter((ownerKey) => + orcadMigrationOwnerMatchesScope(ownerKey, scope) + ) + ) + const removedMarkdownFileIds = new Set() + for (const [ownerKey, files] of Object.entries(session.openFilesByWorktree ?? {})) { + if (!orcadMigrationOwnerMatchesScope(ownerKey, scope)) { + continue + } + for (const file of files) { + markdownFileIdCandidates(file.filePath, ownerKey, file.runtimeEnvironmentId).forEach((id) => + removedMarkdownFileIds.add(id) + ) + } + } + const next = removeWorkspaceSessionOwners(session, ownerKeys) ?? session + // Selection-only sessions and canonical workspace keys need the same scoped retirement. + const retired = next === session ? structuredClone(session) : next + if (retired.activeRepoId && scope.repoIds.has(retired.activeRepoId)) { + retired.activeRepoId = null + } + if (orcadMigrationOwnerMatchesScope(retired.activeWorktreeId, scope)) { + retired.activeWorktreeId = null + } + if (orcadMigrationOwnerMatchesScope(retired.activeWorkspaceKey, scope)) { + retired.activeWorkspaceKey = null + } + if (retired.activeWorkspaceExecutionHostId === scope.hostId) { + retired.activeWorkspaceExecutionHostId = null + } + if (retired.markdownFrontmatterVisible) { + retired.markdownFrontmatterVisible = Object.fromEntries( + Object.entries(retired.markdownFrontmatterVisible).filter( + ([fileId]) => !removedMarkdownFileIds.has(fileId) + ) + ) + } + for (const repoId of scope.repoIds) { + delete retired.terminalTopologyRevisionByRepoId?.[repoId] + } + return retired +} diff --git a/src/main/ssh/orcad-migration-source-retirement.test.ts b/src/main/ssh/orcad-migration-source-retirement.test.ts new file mode 100644 index 000000000000..94a7b1d059d9 --- /dev/null +++ b/src/main/ssh/orcad-migration-source-retirement.test.ts @@ -0,0 +1,143 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { SshTarget } from '../../shared/ssh-types' +import { closeTestStores, createSqliteTestStore } from '../persistence-test-harness' +import { Store } from '../persistence/loading-store/store' +import { commitOrcadMigrationDestination } from './orcad-migration-cutover-coordinator' +import { + listOrcadMigrationSourceCutovers, + writeOrcadMigrationSourceCutover +} from './orcad-migration-cutover-journal' +import { fakeOrcadMigrationDestination } from './orcad-migration-destination-fake' +import { fenceOrcadMigrationSource } from './orcad-migration-source-fence' +import { retireOrcadMigrationSource } from './orcad-migration-source-retirement' +import { SshTargetOrcadClaims } from './ssh-target-orcad-claims' + +const TARGET: SshTarget = { + id: 'ssh-prod', + label: 'Production', + host: 'prod.example.com', + port: 22, + username: 'deploy', + generation: 2 +} + +const directories: string[] = [] +afterEach(async () => { + await closeTestStores() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +async function setup(options: { commit?: boolean; localRepoInGroup?: boolean } = {}) { + const userDataPath = mkdtempSync(join(tmpdir(), 'orcad-source-retirement-')) + directories.push(userDataPath) + const store = createSqliteTestStore(Store, { dataFile: join(userDataPath, 'orca-data.json') }) + store.addSshTarget(TARGET) + const group = store.createProjectGroup({ + name: 'Prod', + parentPath: '/srv', + createdFrom: 'folder-scan', + connectionId: TARGET.id + }) + store.addRepo({ + id: 'repo-1', + path: '/srv/app', + displayName: 'App', + badgeColor: '#737373', + addedAt: 1, + kind: 'git', + connectionId: TARGET.id, + projectGroupId: group.id + }) + store.createFolderWorkspace({ + projectGroupId: group.id, + name: 'Scratch', + folderPath: '/srv/scratch', + connectionId: TARGET.id + }) + if (options.localRepoInGroup) { + store.addRepo({ + id: 'repo-local', + path: '/home/me/local', + displayName: 'Local', + badgeColor: '#737373', + addedAt: 2, + kind: 'git', + projectGroupId: group.id + }) + } + store.upsertSshRemotePtyLease({ targetId: TARGET.id, ptyId: 'old', state: 'terminated' }) + const claims = new SshTargetOrcadClaims(store) + const fenced = await fenceOrcadMigrationSource({ + userDataPath, + store, + claims, + targetId: TARGET.id, + destinationEnvironmentId: 'env-1', + destinationName: 'Managed', + terminalProof: { verdict: 'exited', provenPtyIds: ['old'] }, + hasDirectSshAuthority: () => false + }) + if (fenced.outcome !== 'fenced') { + throw new Error(`expected a fence: ${JSON.stringify(fenced)}`) + } + if (options.commit !== false) { + await commitOrcadMigrationDestination( + { userDataPath, store, claims, destination: fakeOrcadMigrationDestination() }, + fenced.cutover.migrationId + ) + } + const retire = () => + retireOrcadMigrationSource( + { userDataPath, store, environment: null }, + fenced.cutover.migrationId + ) + return { userDataPath, store, retire, migrationId: fenced.cutover.migrationId, groupId: group.id } +} + +describe('retiring a migrated source', () => { + it('removes the migrated rows, folder workspaces and proven leases, then marks the journal', async () => { + const h = await setup() + await expect(h.retire()).resolves.toMatchObject({ phase: 'source-retired' }) + expect(h.store.getRepos()).toEqual([]) + expect(h.store.getFolderWorkspaces()).toEqual([]) + expect(h.store.getProjectGroups()).toEqual([]) + expect(h.store.getSshRemotePtyLeases(TARGET.id)).toEqual([]) + // The target stays: it carries the managed server's tunnel now. + expect(h.store.getSshTarget(TARGET.id)).toBeDefined() + }) + + it('refuses to retire anything before the destination proved its commit', async () => { + const h = await setup({ commit: false }) + await expect(h.retire()).rejects.toThrow('orcad_migration_retire_before_commit') + expect(h.store.getRepos().map((repo) => repo.id)).toEqual(['repo-1']) + }) + + it('refuses when the fence that authorized the migration is gone', async () => { + const h = await setup() + h.store.updateSshTarget(TARGET.id, { owner: undefined }) + await expect(h.retire()).rejects.toThrow('orcad_migration_source_fence_lost') + expect(h.store.getRepos().map((repo) => repo.id)).toEqual(['repo-1']) + }) + + it('finishes the same work after a crash between the profile flush and the journal write', async () => { + const h = await setup() + const [committed] = listOrcadMigrationSourceCutovers(h.userDataPath) + await h.retire() + writeOrcadMigrationSourceCutover(h.userDataPath, committed!) + await expect(h.retire()).resolves.toMatchObject({ phase: 'source-retired' }) + expect(h.store.getRepos()).toEqual([]) + }) + + it('keeps a group that something outside the migration still uses', async () => { + const h = await setup({ localRepoInGroup: true }) + await h.retire() + expect(h.store.getRepos().map((repo) => repo.id)).toEqual(['repo-local']) + expect(h.store.getProjectGroups().map((group) => group.id)).toEqual([h.groupId]) + expect(h.store.getFolderWorkspaces()).toEqual([]) + }) +}) diff --git a/src/main/ssh/orcad-migration-source-retirement.ts b/src/main/ssh/orcad-migration-source-retirement.ts new file mode 100644 index 000000000000..46800d4f6964 --- /dev/null +++ b/src/main/ssh/orcad-migration-source-retirement.ts @@ -0,0 +1,89 @@ +/** + * Retiring a migrated SSH target's source state, only after the journal proves the destination + * committed it. The fenced target itself stays: it now carries the managed server's tunnel. + * + * Order: profile rows retired and flushed, then the journal moves to `source-retired`, then the + * journal compacts away once the server matches it. A crash anywhere repeats idempotent work. + */ +import type { OrcadMigrationSourceCutover } from '../../shared/orcad-migration-source-cutover' +import type { KnownRuntimeEnvironment } from '../../shared/runtime-environments' +import type { Store } from '../persistence' +import { + listOrcadMigrationSourceCutovers, + removeOrcadMigrationSourceCutover, + writeOrcadMigrationSourceCutover +} from './orcad-migration-cutover-journal' +import { environmentMatchesManagedOrcadCutover } from './orcad-managed-migration-status' +import { resolveOrcadMigrationFence } from './orcad-migration-source-fence' + +export type OrcadMigrationRetirementStore = Pick< + Store, + | 'assertOrcadMigrationSourceRetired' + | 'flushPendingOrThrowAsync' + | 'getSshRemotePtyLeases' + | 'getSshTarget' + | 'removeSshRemotePtyLease' + | 'retireOrcadMigrationSourceCatalog' +> + +export async function retireOrcadMigrationSource( + context: { + userDataPath: string + store: OrcadMigrationRetirementStore + /** The registered destination; the journal compacts only once it matches. */ + environment: KnownRuntimeEnvironment | null + now?: () => Date + signal?: AbortSignal + }, + migrationId: string +): Promise { + const cutover = listOrcadMigrationSourceCutovers(context.userDataPath).find( + (entry) => entry.migrationId === migrationId + ) + if (!cutover) { + throw new Error('orcad_migration_source_cutover_not_found') + } + if (cutover.phase !== 'destination-committed' && cutover.phase !== 'source-retired') { + // Why: until the destination proves its commit, the source is the only real copy. + throw new Error('orcad_migration_retire_before_commit') + } + let retired = cutover + if (cutover.phase === 'destination-committed') { + const target = context.store.getSshTarget(cutover.sshTargetId) + const fence = target ? resolveOrcadMigrationFence(context.userDataPath, target) : null + if (fence?.state !== 'fenced' || fence.cutover.migrationId !== cutover.migrationId) { + throw new Error('orcad_migration_source_fence_lost') + } + context.store.retireOrcadMigrationSourceCatalog(cutover.manifest) + retireProvenLeases(context.store, cutover) + await context.store.flushPendingOrThrowAsync({ + signal: context.signal, + drainToStableGeneration: false + }) + context.store.assertOrcadMigrationSourceRetired(cutover.manifest) + retired = { + ...cutover, + phase: 'source-retired', + updatedAt: (context.now ?? (() => new Date()))().toISOString() + } + writeOrcadMigrationSourceCutover(context.userDataPath, retired) + } + if (context.environment && environmentMatchesManagedOrcadCutover(context.environment, retired)) { + removeOrcadMigrationSourceCutover(context.userDataPath, retired.migrationId) + return null + } + return retired +} + +/** Leases the fence proved exited name a relay that no longer serves this host. */ +function retireProvenLeases( + store: OrcadMigrationRetirementStore, + cutover: OrcadMigrationSourceCutover +): void { + const proven = new Set(cutover.provenPtyIds) + for (const lease of store.getSshRemotePtyLeases(cutover.sshTargetId)) { + if (proven.has(lease.ptyId) && (lease.state === 'terminated' || lease.state === 'expired')) { + store.removeSshRemotePtyLease(cutover.sshTargetId, lease.ptyId) + } + } +} diff --git a/src/main/ssh/orcad-runtime-conversion.test.ts b/src/main/ssh/orcad-runtime-conversion.test.ts index bbe2abbecb48..a04374e2c4d8 100644 --- a/src/main/ssh/orcad-runtime-conversion.test.ts +++ b/src/main/ssh/orcad-runtime-conversion.test.ts @@ -104,20 +104,27 @@ const convert = (listRelayPtyIds: (() => Promise) | null = asyn }) describe('converting an SSH host into a managed server', () => { - it('fences, deploys, marks the server, then commits the catalog exactly once', async () => { + it('fences, deploys, marks the server, commits once, then retires the source', async () => { const result = await convert() - expect(result).toMatchObject({ outcome: 'committed' }) + expect(result).toMatchObject({ outcome: 'converted' }) expect(releaseDirectSession).toHaveBeenCalledWith(TARGET.id) expect(mocks.deploy).toHaveBeenCalledWith( userDataPath, expect.objectContaining({ migration: true }) ) - expect(listOrcadMigrationSourceCutovers(userDataPath)[0]?.phase).toBe('destination-committed') const [environment] = listEnvironments(userDataPath) expect(environment?.orcadMigratedAt).toBe('2026-10-02T00:00:00.000Z') expect(destination.commits).toBe(1) - // Retiring the source is the next step; until then it keeps every row. - expect(store.getRepos().map((repo) => repo.id)).toEqual(['repo-1']) + expect(store.getRepos()).toEqual([]) + // The journal compacts once the server matches it; the fenced target stays for the tunnel. + expect(listOrcadMigrationSourceCutovers(userDataPath)).toEqual([]) + expect(getManagedOrcadOwnerEnvironmentId(store.getSshTarget(TARGET.id)?.owner)).toBe( + environment?.id + ) + await expect(convert()).resolves.toMatchObject({ + outcome: 'refused', + code: 'orcad_migration_already_managed' + }) }) it('refuses before touching the host while terminals run or cannot be counted', async () => { @@ -142,10 +149,10 @@ describe('converting an SSH host into a managed server', () => { const [fenced] = listOrcadMigrationSourceCutovers(userDataPath) expect(fenced?.phase).toBe('source-fenced') await expect(convert(null)).resolves.toMatchObject({ - outcome: 'committed', + outcome: 'converted', migrationId: fenced?.migrationId }) - expect(listOrcadMigrationSourceCutovers(userDataPath)).toHaveLength(1) + expect(destination.commits).toBe(1) }) it('resumes after a lost commit reply without committing twice', async () => { @@ -167,8 +174,9 @@ describe('converting an SSH host into a managed server', () => { await expect(convert()).rejects.toThrow('socket closed') expect(listOrcadMigrationSourceCutovers(userDataPath)[0]?.phase).toBe('destination-staged') reachable = true - await expect(convert(null)).resolves.toMatchObject({ outcome: 'committed' }) + await expect(convert(null)).resolves.toMatchObject({ outcome: 'converted' }) expect(destination.commits).toBe(1) - expect(listOrcadMigrationSourceCutovers(userDataPath)[0]?.phase).toBe('destination-committed') + expect(listOrcadMigrationSourceCutovers(userDataPath)).toEqual([]) + expect(store.getRepos()).toEqual([]) }) }) diff --git a/src/main/ssh/orcad-runtime-conversion.ts b/src/main/ssh/orcad-runtime-conversion.ts index a9e81a74950a..9de3c67081a5 100644 --- a/src/main/ssh/orcad-runtime-conversion.ts +++ b/src/main/ssh/orcad-runtime-conversion.ts @@ -1,10 +1,11 @@ /** * Converting a relay-hosted SSH target that holds Orca state into a managed orcad server: - * fence the source, deploy and pair the server, then stage and commit the dormant catalog. + * fence the source, deploy and pair the server, stage and commit the dormant catalog, then + * retire the source. * * Every step is keyed by the journal, so calling this again after a crash or lost contact resumes * the same migration instead of starting another. The source stays authoritative until the - * destination proves its commit; retiring the source is a separate, later step. + * destination proves its commit, and only then is retired. */ import { randomUUID } from 'node:crypto' import type { OrcadManagedConversionResult } from '../../shared/orcad-managed-runtime' @@ -32,6 +33,7 @@ import { type ListRelayPtyIds } from './orcad-migration-terminal-gate' import { createManagedOrcadEnvironment } from './orcad-runtime-deployment' +import { retireOrcadMigrationSource } from './orcad-migration-source-retirement' import { hasRegisteredDirectSshAuthority } from './ssh-target-registry' export type OrcadManagedConversionArgs = { @@ -93,8 +95,20 @@ export async function convertSshTargetToManagedOrcad( if (committed.phase !== 'destination-committed' && committed.phase !== 'source-retired') { throw new Error(`orcad_migration_commit_not_proven:${committed.phase}`) } + await runTargetLifecycle(fenced.sshTargetId, () => + retireOrcadMigrationSource( + { + userDataPath, + store: targetStore.getOrcadMigrationSource(), + environment: marked, + now: args.now, + signal: args.signal + }, + committed.migrationId + ) + ) return { - outcome: 'committed', + outcome: 'converted', environment: redactRuntimeEnvironment(marked), migrationId: committed.migrationId } @@ -115,6 +129,12 @@ async function fenceOrResume( if (existing.state === 'fenced') { return existing.cutover } + const converted = listEnvironments(userDataPath).some( + (environment) => environment.orcadDeployment?.sshTargetId === target.id + ) + if (converted) { + return refuse('live', 'orcad_migration_already_managed', 'This SSH host is already a managed server.') + } const store = targetStore.getOrcadMigrationSource() // Asked while the relay still answers; the fence re-checks leases once it holds. const terminalProof = await assessOrcadMigrationTerminals(store, target.id, args.listRelayPtyIds) diff --git a/src/shared/orcad-managed-runtime.ts b/src/shared/orcad-managed-runtime.ts index 11292d0a8a8a..4b1064faefbf 100644 --- a/src/shared/orcad-managed-runtime.ts +++ b/src/shared/orcad-managed-runtime.ts @@ -106,7 +106,8 @@ export type OrcadManagedRuntimeStatus = { export type OrcadManagedConversionResult = | { - outcome: 'committed' + /** Committed on the server and retired from the SSH host. */ + outcome: 'converted' environment: PublicKnownRuntimeEnvironment migrationId: string } From 2d2d5d5a2bb52d90a6c2b5e5fe11d59fcaf69299 Mon Sep 17 00:00:00 2001 From: m4air Date: Fri, 2 Oct 2026 00:59:50 -0700 Subject: [PATCH 2/3] fix(orcad): retirement drops the migrated host from the reconnect hint The census no longer treats activeConnectionIdsAtShutdown as untransferable (#24609), so retirement must remove the target from it; otherwise a restart dials a host that is now a managed server. --- .../orcad-source-retirement.ts | 2 ++ .../orcad-source-workspace-session-retirement.ts | 12 ++++++++++++ .../ssh/orcad-migration-source-retirement.test.ts | 3 +++ 3 files changed, 17 insertions(+) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-retirement.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-retirement.ts index 7b5d74750871..38ca063a95af 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-retirement.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-retirement.ts @@ -15,6 +15,7 @@ import { assertOrcadMigrationSourceDormantStateRetired, retireOrcadMigrationSourceDormantState } from './orcad-source-dormant-retirement' +import { retireOrcadSourceReconnectHint } from './orcad-source-workspace-session-retirement' const orcadSourceRetirementContext = Symbol('OrcadSourceRetirementPersistence') type OrcadSourceRetirementContext = { @@ -41,6 +42,7 @@ export class OrcadSourceRetirementPersistence { const state = context.runtime.state retireOrcadSourceCatalogState(state, manifest) retireOrcadMigrationSourceDormantState(state, manifest) + retireOrcadSourceReconnectHint(state, manifest.source.sshTargetId) syncProjectHostSetupCompatibilityState(context.repos) scheduleSave(context.scheduling) } diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-retirement.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-retirement.ts index c13e783fbcfa..be1f7c87d481 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-retirement.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-retirement.ts @@ -34,6 +34,18 @@ export function retireOrcadMigrationSourceWorkspaceSession( } } +/** The census lets this hint through, so retirement drops it: a restart must not dial a managed host. */ +export function retireOrcadSourceReconnectHint(state: PersistedState, targetId: string): void { + const sessions = [state.workspaceSession, ...Object.values(state.workspaceSessionsByHostId ?? {})] + for (const session of sessions) { + if (!session?.activeConnectionIdsAtShutdown?.includes(targetId)) { + continue + } + const remaining = session.activeConnectionIdsAtShutdown.filter((id) => id !== targetId) + session.activeConnectionIdsAtShutdown = remaining.length > 0 ? remaining : undefined + } +} + export function assertOrcadMigrationSourceWorkspaceSessionRetired( state: PersistedState, manifest: OrcadMigrationManifest diff --git a/src/main/ssh/orcad-migration-source-retirement.test.ts b/src/main/ssh/orcad-migration-source-retirement.test.ts index 94a7b1d059d9..bbd007cbb6de 100644 --- a/src/main/ssh/orcad-migration-source-retirement.test.ts +++ b/src/main/ssh/orcad-migration-source-retirement.test.ts @@ -102,6 +102,8 @@ async function setup(options: { commit?: boolean; localRepoInGroup?: boolean } = describe('retiring a migrated source', () => { it('removes the migrated rows, folder workspaces and proven leases, then marks the journal', async () => { const h = await setup() + // The renderer may still be writing the reconnect hint for the released session. + h.store.patchWorkspaceSession({ activeConnectionIdsAtShutdown: [TARGET.id, 'ssh-other'] }) await expect(h.retire()).resolves.toMatchObject({ phase: 'source-retired' }) expect(h.store.getRepos()).toEqual([]) expect(h.store.getFolderWorkspaces()).toEqual([]) @@ -109,6 +111,7 @@ describe('retiring a migrated source', () => { expect(h.store.getSshRemotePtyLeases(TARGET.id)).toEqual([]) // The target stays: it carries the managed server's tunnel now. expect(h.store.getSshTarget(TARGET.id)).toBeDefined() + expect(h.store.getWorkspaceSession().activeConnectionIdsAtShutdown).toEqual(['ssh-other']) }) it('refuses to retire anything before the destination proved its commit', async () => { From 597ce2380977134ac769fc926b9ddb6950ca864b Mon Sep 17 00:00:00 2001 From: m4air Date: Fri, 2 Oct 2026 01:36:17 -0700 Subject: [PATCH 3/3] style: oxfmt the c5 conversion file --- src/main/ssh/orcad-runtime-conversion.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/ssh/orcad-runtime-conversion.ts b/src/main/ssh/orcad-runtime-conversion.ts index 9de3c67081a5..19f698e554ad 100644 --- a/src/main/ssh/orcad-runtime-conversion.ts +++ b/src/main/ssh/orcad-runtime-conversion.ts @@ -133,7 +133,11 @@ async function fenceOrResume( (environment) => environment.orcadDeployment?.sshTargetId === target.id ) if (converted) { - return refuse('live', 'orcad_migration_already_managed', 'This SSH host is already a managed server.') + return refuse( + 'live', + 'orcad_migration_already_managed', + 'This SSH host is already a managed server.' + ) } const store = targetStore.getOrcadMigrationSource() // Asked while the relay still answers; the fence re-checks leases once it holds.