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..38ca063a95af --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-retirement.ts @@ -0,0 +1,75 @@ +/** + * 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' +import { retireOrcadSourceReconnectHint } from './orcad-source-workspace-session-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) + retireOrcadSourceReconnectHint(state, manifest.source.sshTargetId) + 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..be1f7c87d481 --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-retirement.ts @@ -0,0 +1,112 @@ +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 + ]) + ) + } +} + +/** 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 +): 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..bbd007cbb6de --- /dev/null +++ b/src/main/ssh/orcad-migration-source-retirement.test.ts @@ -0,0 +1,146 @@ +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() + // 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([]) + 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() + expect(h.store.getWorkspaceSession().activeConnectionIdsAtShutdown).toEqual(['ssh-other']) + }) + + 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..19f698e554ad 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,16 @@ 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 }