mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 08:02:35 +00:00
fix(orcad): client focus and 'local'-stamped tabs no longer refuse a host's move to its managed server (#25110)
* fix(orcad): client focus and 'local'-stamped tabs no longer refuse a host's move to its managed server * fix(orcad): a v1.4.218 profile focused on the SSH worktree converts, and a refusal names what blocks it The debounced session writer never patched activeWorkspaceKey or activeWorkspaceExecutionHostId, so the first focus stayed on disk. The all-dependency census also counted global focus copies in every non-source partition. --------- Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
+231
@@ -0,0 +1,231 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getDefaultPersistedState } from '../../../shared/constants'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import {
|
||||
ORCAD_MIGRATION_MANIFEST_VERSION,
|
||||
type OrcadMigrationManifest
|
||||
} from '../../../shared/orcad-migration-manifest'
|
||||
import type { PersistedState } from '../../../shared/persisted-state-types'
|
||||
import type { Repo } from '../../../shared/repo-types'
|
||||
import type { SshTarget } from '../../../shared/ssh-types'
|
||||
import type { Tab } from '../../../shared/tab-types'
|
||||
import { worktreeWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import { retargetOrcadSourceClientFocus } from './orcad-source-client-focus-retarget'
|
||||
import {
|
||||
collectOrcadMigrationSourceDependencyCensus,
|
||||
collectOrcadMigrationUntransferredDependencyCensus
|
||||
} from './orcad-source-dependency-census'
|
||||
import { collectOrcadMigrationSourceDormantState } from './orcad-source-dormant-state'
|
||||
import {
|
||||
assertOrcadMigrationSourceWorkspaceSessionRetired,
|
||||
retireOrcadMigrationSourceWorkspaceSession
|
||||
} from './orcad-source-workspace-session-retirement'
|
||||
|
||||
const TARGET: SshTarget = { id: 'ssh-prod', label: 'Prod', host: 'prod', port: 22, username: 'u' }
|
||||
const SSH_HOST = `ssh:${TARGET.id}` as const
|
||||
const REPO: Repo = {
|
||||
id: 'repo-1',
|
||||
path: '/srv/repo',
|
||||
displayName: 'Repository',
|
||||
badgeColor: '#737373',
|
||||
addedAt: 1,
|
||||
connectionId: TARGET.id,
|
||||
executionHostId: SSH_HOST
|
||||
}
|
||||
const WORKTREE = `${REPO.id}::/srv/repo`
|
||||
|
||||
function manifest(destinationEnvironmentId?: string): OrcadMigrationManifest {
|
||||
return {
|
||||
version: ORCAD_MIGRATION_MANIFEST_VERSION,
|
||||
migrationId: 'migration-1',
|
||||
createdAt: '2026-10-03T12:00:00.000Z',
|
||||
source: { sshTargetId: TARGET.id, sshTargetGeneration: null, targetLabel: TARGET.label },
|
||||
payload: { repositories: [REPO], projectGroups: [], folderWorkspaces: [] },
|
||||
...(destinationEnvironmentId ? { destinationEnvironmentId } : {}),
|
||||
manifestSha256: 'a'.repeat(64)
|
||||
}
|
||||
}
|
||||
|
||||
function editorTab(executionHostId?: ExecutionHostId): Tab {
|
||||
return {
|
||||
id: 'tab-1',
|
||||
entityId: '/srv/repo/README.md',
|
||||
groupId: 'group-1',
|
||||
worktreeId: WORKTREE,
|
||||
...(executionHostId ? { executionHostId } : {}),
|
||||
contentType: 'editor',
|
||||
label: 'README.md',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
function sourceState(): PersistedState {
|
||||
const state = getDefaultPersistedState('/home/test')
|
||||
state.sshTargets = [TARGET]
|
||||
state.repos = [REPO]
|
||||
return state
|
||||
}
|
||||
|
||||
function focusLocalOnSourceWorktree(state: PersistedState): void {
|
||||
state.workspaceSession = {
|
||||
...state.workspaceSession,
|
||||
activeRepoId: REPO.id,
|
||||
activeWorktreeId: WORKTREE,
|
||||
activeWorkspaceKey: worktreeWorkspaceKey(WORKTREE),
|
||||
activeWorkspaceExecutionHostId: SSH_HOST,
|
||||
activeTabId: 'tab-1'
|
||||
}
|
||||
}
|
||||
|
||||
const sessionCount = (state: PersistedState): number =>
|
||||
collectOrcadMigrationUntransferredDependencyCensus(state, manifest('env-1')).counts[
|
||||
'workspace-session'
|
||||
]
|
||||
|
||||
describe('orcad migration census and client focus', () => {
|
||||
it('never blocks a move on client focus aimed at a migrating worktree', () => {
|
||||
const state = sourceState()
|
||||
focusLocalOnSourceWorktree(state)
|
||||
|
||||
expect(sessionCount(state)).toBe(0)
|
||||
})
|
||||
|
||||
it('passes a v1.4.218 profile quit while focused on the source worktree', () => {
|
||||
// v1.4.218 copied the global focus fields into 'local' and into every host partition it wrote.
|
||||
const state = sourceState()
|
||||
focusLocalOnSourceWorktree(state)
|
||||
const focusCopy = {
|
||||
...state.workspaceSession,
|
||||
unifiedTabs: {},
|
||||
tabsByWorktree: {}
|
||||
}
|
||||
state.workspaceSessionsByHostId = {
|
||||
[SSH_HOST]: { ...focusCopy, unifiedTabs: { [WORKTREE]: [editorTab('local')] } },
|
||||
'ssh:other-a': { ...focusCopy },
|
||||
'ssh:other-b': { ...focusCopy }
|
||||
}
|
||||
// The same profile with focus only in the source partition: what the census may count.
|
||||
const sourceOnly = structuredClone(state)
|
||||
const noFocus = {
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: null,
|
||||
activeWorkspaceKey: null,
|
||||
activeWorkspaceExecutionHostId: null,
|
||||
activeTabId: null
|
||||
}
|
||||
Object.assign(sourceOnly.workspaceSession, noFocus)
|
||||
Object.assign(sourceOnly.workspaceSessionsByHostId!['ssh:other-a']!, noFocus)
|
||||
Object.assign(sourceOnly.workspaceSessionsByHostId!['ssh:other-b']!, noFocus)
|
||||
|
||||
expect(sessionCount(state)).toBe(0)
|
||||
expect(
|
||||
collectOrcadMigrationSourceDependencyCensus(state, manifest('env-1')).counts[
|
||||
'workspace-session'
|
||||
]
|
||||
).toBe(
|
||||
collectOrcadMigrationSourceDependencyCensus(sourceOnly, manifest('env-1')).counts[
|
||||
'workspace-session'
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
it('carries no client focus into the destination session', () => {
|
||||
const state = sourceState()
|
||||
focusLocalOnSourceWorktree(state)
|
||||
state.workspaceSession.unifiedTabs = { [WORKTREE]: [editorTab(SSH_HOST)] }
|
||||
|
||||
const session = collectOrcadMigrationSourceDormantState(state, manifest().source, {
|
||||
repositories: [REPO],
|
||||
projectGroups: [],
|
||||
folderWorkspaces: []
|
||||
}).payload.workspaceSession
|
||||
|
||||
expect(session?.unifiedTabs?.[WORKTREE]).toHaveLength(1)
|
||||
expect(session?.activeWorktreeId ?? null).toBeNull()
|
||||
expect(session?.activeWorkspaceKey ?? null).toBeNull()
|
||||
})
|
||||
|
||||
it('remaps client focus to the same worktree in the managed environment', () => {
|
||||
const state = sourceState()
|
||||
focusLocalOnSourceWorktree(state)
|
||||
|
||||
retargetOrcadSourceClientFocus(state, manifest('env-1'))
|
||||
|
||||
expect(state.workspaceSession).toMatchObject({
|
||||
activeRepoId: REPO.id,
|
||||
activeWorktreeId: WORKTREE,
|
||||
activeWorkspaceKey: worktreeWorkspaceKey(WORKTREE),
|
||||
activeWorkspaceExecutionHostId: 'runtime:env-1',
|
||||
activeTabId: 'tab-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('clears client focus the managed environment cannot resolve', () => {
|
||||
const state = sourceState()
|
||||
focusLocalOnSourceWorktree(state)
|
||||
const unmoved = sourceState()
|
||||
focusLocalOnSourceWorktree(unmoved)
|
||||
unmoved.workspaceSession.activeWorktreeId = 'other-repo::/srv/other'
|
||||
unmoved.workspaceSession.activeWorkspaceKey = worktreeWorkspaceKey('other-repo::/srv/other')
|
||||
|
||||
retargetOrcadSourceClientFocus(state, manifest())
|
||||
retargetOrcadSourceClientFocus(unmoved, manifest('env-1'))
|
||||
|
||||
for (const session of [state.workspaceSession, unmoved.workspaceSession]) {
|
||||
expect(session).toMatchObject({
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: null,
|
||||
activeWorkspaceKey: null,
|
||||
activeWorkspaceExecutionHostId: null,
|
||||
activeTabId: null
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps remapped focus through session retirement, which then verifies clean', () => {
|
||||
const state = sourceState()
|
||||
focusLocalOnSourceWorktree(state)
|
||||
state.workspaceSession.unifiedTabs = { [WORKTREE]: [editorTab(SSH_HOST)] }
|
||||
const moved = manifest('env-1')
|
||||
moved.payload.dormantState = collectOrcadMigrationSourceDormantState(
|
||||
state,
|
||||
moved.source,
|
||||
moved.payload
|
||||
).payload
|
||||
|
||||
retargetOrcadSourceClientFocus(state, moved)
|
||||
retireOrcadMigrationSourceWorkspaceSession(state, moved)
|
||||
|
||||
expect(state.workspaceSession.unifiedTabs?.[WORKTREE]).toBeUndefined()
|
||||
expect(state.workspaceSession).toMatchObject({
|
||||
activeWorktreeId: WORKTREE,
|
||||
activeWorkspaceExecutionHostId: 'runtime:env-1'
|
||||
})
|
||||
expect(() => assertOrcadMigrationSourceWorkspaceSessionRetired(state, moved)).not.toThrow()
|
||||
})
|
||||
|
||||
it("counts a 'local'-stamped tab in a migrating SSH worktree as the SSH host's", () => {
|
||||
const local = sourceState()
|
||||
local.workspaceSession.unifiedTabs = { [WORKTREE]: [editorTab('local')] }
|
||||
const partitioned = sourceState()
|
||||
partitioned.workspaceSessionsByHostId = {
|
||||
[SSH_HOST]: {
|
||||
...partitioned.workspaceSession,
|
||||
unifiedTabs: { [WORKTREE]: [editorTab('local')] }
|
||||
}
|
||||
}
|
||||
|
||||
expect(sessionCount(local)).toBe(0)
|
||||
expect(sessionCount(partitioned)).toBe(0)
|
||||
})
|
||||
|
||||
it('still blocks a tab another host owns inside a migrating worktree', () => {
|
||||
const state = sourceState()
|
||||
state.workspaceSession.unifiedTabs = { [WORKTREE]: [editorTab('ssh:other')] }
|
||||
|
||||
expect(sessionCount(state)).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { parseExecutionHostId, toRuntimeExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { OrcadMigrationManifest } from '../../../shared/orcad-migration-manifest'
|
||||
import type { PersistedState } from '../../../shared/persisted-state-types'
|
||||
import { isWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
import {
|
||||
createOrcadMigrationSourceScope,
|
||||
orcadMigrationOwnerMatchesScope,
|
||||
unqualifyOrcadMigrationOwnerKey,
|
||||
type OrcadMigrationSourceScope
|
||||
} from './orcad-source-scope'
|
||||
|
||||
/**
|
||||
* Client focus on a moved worktree follows it to the managed environment, or is cleared when the
|
||||
* destination cannot resolve it. The census never blocks on this focus, so retirement owns it.
|
||||
*/
|
||||
export function retargetOrcadSourceClientFocus(
|
||||
state: PersistedState,
|
||||
manifest: OrcadMigrationManifest
|
||||
): void {
|
||||
const scope = createOrcadMigrationSourceScope({
|
||||
source: manifest.source,
|
||||
catalog: manifest.payload
|
||||
})
|
||||
const destinationHostId = manifest.destinationEnvironmentId
|
||||
? toRuntimeExecutionHostId(manifest.destinationEnvironmentId)
|
||||
: null
|
||||
retargetSessionFocus(state.workspaceSession, scope, destinationHostId)
|
||||
for (const [hostId, session] of Object.entries(state.workspaceSessionsByHostId ?? {})) {
|
||||
if (session && hostId !== scope.hostId) {
|
||||
retargetSessionFocus(session, scope, destinationHostId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Focus already on a runtime host names that host's workspace, never the SSH source's. */
|
||||
export function isOrcadRuntimeHostFocus(session: WorkspaceSessionState): boolean {
|
||||
return parseExecutionHostId(session.activeWorkspaceExecutionHostId)?.kind === 'runtime'
|
||||
}
|
||||
|
||||
function retargetSessionFocus(
|
||||
session: WorkspaceSessionState,
|
||||
scope: OrcadMigrationSourceScope,
|
||||
destinationHostId: `runtime:${string}` | null
|
||||
): void {
|
||||
const owns = (value: string | null | undefined): value is string =>
|
||||
orcadMigrationOwnerMatchesScope(value, scope)
|
||||
const focusHost = session.activeWorkspaceExecutionHostId
|
||||
const aimedAtSource =
|
||||
focusHost === scope.hostId ||
|
||||
(!focusHost && (owns(session.activeWorktreeId) || owns(session.activeWorkspaceKey)))
|
||||
if (!aimedAtSource) {
|
||||
return
|
||||
}
|
||||
const worktreeId = owns(session.activeWorktreeId)
|
||||
? unqualifyOrcadMigrationOwnerKey(session.activeWorktreeId)
|
||||
: null
|
||||
const workspaceKey = owns(session.activeWorkspaceKey)
|
||||
? unqualifyOrcadMigrationOwnerKey(session.activeWorkspaceKey)
|
||||
: null
|
||||
if (destinationHostId && (worktreeId || workspaceKey)) {
|
||||
session.activeWorktreeId = worktreeId
|
||||
session.activeWorkspaceKey = workspaceKey && isWorkspaceKey(workspaceKey) ? workspaceKey : null
|
||||
session.activeWorkspaceExecutionHostId = destinationHostId
|
||||
return
|
||||
}
|
||||
session.activeWorktreeId = null
|
||||
session.activeWorkspaceKey = null
|
||||
session.activeWorkspaceExecutionHostId = null
|
||||
session.activeTabId = null
|
||||
if (session.activeRepoId && scope.repoIds.has(session.activeRepoId)) {
|
||||
session.activeRepoId = null
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
retireOrcadMigrationSourceDormantState
|
||||
} from './orcad-source-dormant-retirement'
|
||||
import { retireOrcadSourceReconnectHint } from './orcad-source-workspace-session-retirement'
|
||||
import { retargetOrcadSourceClientFocus } from './orcad-source-client-focus-retarget'
|
||||
|
||||
const orcadSourceRetirementContext = Symbol('OrcadSourceRetirementPersistence')
|
||||
type OrcadSourceRetirementContext = {
|
||||
@@ -42,6 +43,7 @@ export class OrcadSourceRetirementPersistence {
|
||||
const context = this[orcadSourceRetirementContext]
|
||||
const state = context.runtime.state
|
||||
retireOrcadSourceCatalogState(state, manifest)
|
||||
retargetOrcadSourceClientFocus(state, manifest)
|
||||
retireOrcadMigrationSourceDormantState(state, manifest)
|
||||
retireOrcadSourceReconnectHint(state, manifest.source.sshTargetId)
|
||||
syncProjectHostSetupCompatibilityState(context.repos)
|
||||
|
||||
@@ -110,6 +110,10 @@ function inspectSession(
|
||||
}
|
||||
}
|
||||
result.dependencyCount += (session.activeWorktreeIdsOnShutdown ?? []).filter(matchesOwner).length
|
||||
if (!sourceHostPartition) {
|
||||
// Global fields here are copies of client focus and history, not this host's state.
|
||||
return result
|
||||
}
|
||||
result.dependencyCount += session.activeRepoId && scope.ownerMatches(session.activeRepoId) ? 1 : 0
|
||||
result.dependencyCount += matchesOwner(session.activeWorktreeId ?? '') ? 1 : 0
|
||||
result.dependencyCount += matchesOwner(session.activeWorkspaceKey ?? '') ? 1 : 0
|
||||
|
||||
+10
-11
@@ -79,22 +79,15 @@ export function countUnsupportedSessionState(
|
||||
shutdownMarkerHasTerminalAuthority(state, session, scope, sourceHostPartition, worktreeId)
|
||||
).length
|
||||
count +=
|
||||
sourceHostPartition &&
|
||||
session.activeRepoId &&
|
||||
owns(session.activeRepoId) &&
|
||||
!(sourceHostPartition && scope.repoIds.has(session.activeRepoId))
|
||||
!scope.repoIds.has(session.activeRepoId)
|
||||
? 1
|
||||
: 0
|
||||
// Why not in the source partition: there focus is a copy of the client-wide focus. An in-scope
|
||||
// copy is carried; one aimed at another host's workspace is not host state and is never carried.
|
||||
count +=
|
||||
!sourceHostPartition && session.activeWorktreeId && owns(session.activeWorktreeId) ? 1 : 0
|
||||
count +=
|
||||
!sourceHostPartition && session.activeWorkspaceKey && owns(session.activeWorkspaceKey) ? 1 : 0
|
||||
count += session.activeWorkspaceExecutionHostId === scope.hostId && !sourceHostPartition ? 1 : 0
|
||||
// Focus outside the source partition is client focus, not host state: retirement retargets it.
|
||||
// activeConnectionIdsAtShutdown is not counted: it is the renderer's live "connected now" hint, and
|
||||
// the remote work it can stand for (tab PTYs, remote session ids, leases) is counted on its own.
|
||||
count +=
|
||||
session.activeTabId && terminalTabIds.has(session.activeTabId) && !sourceHostPartition ? 1 : 0
|
||||
for (const [ownerKey, files] of Object.entries(session.openFilesByWorktree ?? {})) {
|
||||
if (owns(ownerKey)) {
|
||||
count += files.filter(
|
||||
@@ -112,7 +105,8 @@ export function countUnsupportedSessionState(
|
||||
for (const [ownerKey, tabs] of Object.entries(session.unifiedTabs ?? {})) {
|
||||
if (owns(ownerKey)) {
|
||||
count += tabs.filter(
|
||||
(tab) => tab.executionHostId !== undefined && tab.executionHostId !== scope.hostId
|
||||
(tab) =>
|
||||
tab.executionHostId !== undefined && !isOrcadSourceTabHost(tab.executionHostId, scope)
|
||||
).length
|
||||
}
|
||||
}
|
||||
@@ -206,6 +200,11 @@ export function projectDormantSessionFocus(
|
||||
}
|
||||
}
|
||||
|
||||
/** Older builds stamped 'local' on tabs created in an SSH worktree; its owner key proves the host. */
|
||||
function isOrcadSourceTabHost(executionHostId: string, scope: OrcadMigrationSourceScope): boolean {
|
||||
return executionHostId === scope.hostId || executionHostId === LOCAL_EXECUTION_HOST_ID
|
||||
}
|
||||
|
||||
export function projectSessionToDestination(
|
||||
session: WorkspaceSessionState,
|
||||
scope: OrcadMigrationSourceScope
|
||||
|
||||
+22
-8
@@ -10,6 +10,7 @@ import { collectSessionOwnerKeys } from './orcad-source-workspace-session-fragme
|
||||
import type { OrcadMigrationManifest } from '../../../shared/orcad-migration-manifest'
|
||||
import type { PersistedState } from '../../../shared/persisted-state-types'
|
||||
import { collectOrcadMigrationSourceWorkspaceSession } from './orcad-source-workspace-session'
|
||||
import { isOrcadRuntimeHostFocus } from './orcad-source-client-focus-retarget'
|
||||
|
||||
export function retireOrcadMigrationSourceWorkspaceSession(
|
||||
state: PersistedState,
|
||||
@@ -83,17 +84,30 @@ export function removeOwnedSessionState(
|
||||
)
|
||||
}
|
||||
}
|
||||
// Focus retargeted to the destination names the same ids on the managed host; keep it.
|
||||
const retargetedFocus = isOrcadRuntimeHostFocus(session)
|
||||
? {
|
||||
activeRepoId: session.activeRepoId,
|
||||
activeWorktreeId: session.activeWorktreeId,
|
||||
activeWorkspaceKey: session.activeWorkspaceKey,
|
||||
activeTabId: session.activeTabId
|
||||
}
|
||||
: null
|
||||
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 (retargetedFocus) {
|
||||
Object.assign(retired, retargetedFocus)
|
||||
} else {
|
||||
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
|
||||
|
||||
@@ -80,6 +80,11 @@ export function collectOrcadMigrationSourceWorkspaceSession(
|
||||
}
|
||||
: null
|
||||
})
|
||||
if (!sourceHostPartition) {
|
||||
// Client focus is not host state; retirement retargets it instead of the destination carrying it.
|
||||
fragment.activeWorktreeId = null
|
||||
delete fragment.activeWorkspaceKey
|
||||
}
|
||||
// A shutdown marker is only a reconnect hint. Once the source has no live
|
||||
// PTY authority for that worktree, carrying it would make the destination
|
||||
// try to resurrect a process that no longer exists.
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
} from './orcad-migration-terminal-gate'
|
||||
import { currentOrcadSourceFingerprint } from './orcad-retained-source'
|
||||
import type { SshTargetOrcadClaims } from './ssh-target-orcad-claims'
|
||||
import { orcadMigrationRefusalReason } from './orcad-migration-refusal-reason'
|
||||
|
||||
export type OrcadDeltaMoveArgs = {
|
||||
userDataPath: string
|
||||
@@ -58,7 +59,7 @@ export async function runOrcadDeltaMove(args: OrcadDeltaMoveArgs): Promise<Orcad
|
||||
}
|
||||
if (plan.blockers.length > 0) {
|
||||
return {
|
||||
...refuse('orcad_migration_preflight_blocked', 'This SSH host cannot move yet.'),
|
||||
...refuse('orcad_migration_preflight_blocked', orcadMigrationRefusalReason(plan.blockers)),
|
||||
blockers: plan.blockers
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { orcadMigrationRefusalReason } from './orcad-migration-refusal-reason'
|
||||
|
||||
describe('orcadMigrationRefusalReason', () => {
|
||||
it('names each blocking kind once, in plain words', () => {
|
||||
expect(
|
||||
orcadMigrationRefusalReason([
|
||||
{
|
||||
code: 'orcad_migration_direct_ssh_repositories',
|
||||
category: 'drainable-static-state',
|
||||
repositories: []
|
||||
},
|
||||
{
|
||||
code: 'orcad_migration_dependent_state',
|
||||
category: 'client-owned-state',
|
||||
dependencies: [
|
||||
{ kind: 'workspace-session', count: 7 },
|
||||
{ kind: 'worktree-lineage', count: 1 },
|
||||
{ kind: 'workspace-lineage', count: 1 },
|
||||
{ kind: 'automation', count: 2 }
|
||||
]
|
||||
},
|
||||
{
|
||||
code: 'orcad_migration_direct_ssh_terminal_leases',
|
||||
category: 'live-or-unverifiable',
|
||||
terminalLeases: []
|
||||
}
|
||||
])
|
||||
).toBe(
|
||||
'This SSH host cannot move yet: saved tabs and panes, workspace history, automations, terminals still running.'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the bare refusal when nothing that blocks is named', () => {
|
||||
expect(orcadMigrationRefusalReason([])).toBe('This SSH host cannot move yet.')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import type {
|
||||
OrcadMigrationBlocker,
|
||||
OrcadMigrationDependencyKind
|
||||
} from '../../shared/orcad-migration-preflight'
|
||||
|
||||
const DEPENDENCY_WORDS: Record<OrcadMigrationDependencyKind, string> = {
|
||||
'saved-port-forward': 'saved port forwards',
|
||||
'terminal-lease': 'terminals still running',
|
||||
'terminal-recovery': 'terminals waiting to reconnect',
|
||||
'workspace-session': 'saved tabs and panes',
|
||||
'worktree-metadata': 'saved workspace details',
|
||||
'worktree-lineage': 'workspace history',
|
||||
'workspace-lineage': 'workspace history',
|
||||
'sparse-preset': 'sparse checkout presets',
|
||||
'retired-worktree-name': 'retired workspace names',
|
||||
automation: 'automations',
|
||||
'automation-run': 'automation runs',
|
||||
'mobile-tab-selection': 'mobile tab selections',
|
||||
'ui-routing': 'sidebar settings'
|
||||
}
|
||||
|
||||
function blockerWords(blocker: OrcadMigrationBlocker): string[] {
|
||||
switch (blocker.code) {
|
||||
case 'orcad_migration_target_not_found':
|
||||
return ['the host is no longer saved']
|
||||
case 'orcad_migration_target_owned':
|
||||
return ['another server holds it']
|
||||
case 'orcad_migration_owner_unrecorded':
|
||||
return ['its server record is missing']
|
||||
case 'orcad_migration_direct_ssh_terminal_leases':
|
||||
return ['terminals still running']
|
||||
case 'orcad_migration_dependent_state':
|
||||
return blocker.dependencies.map(({ kind }) => DEPENDENCY_WORDS[kind])
|
||||
case 'orcad_migration_dependency_unverifiable':
|
||||
return ['saved state Orca could not read']
|
||||
case 'orcad_migration_direct_ssh_repositories':
|
||||
case 'orcad_migration_direct_ssh_folder_workspaces':
|
||||
case 'orcad_migration_saved_port_forwards':
|
||||
// These move with the host or stay behind; they never refuse a conversion.
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** The refusal names what blocks, in plain words; the status line shows only this text. */
|
||||
export function orcadMigrationRefusalReason(blockers: readonly OrcadMigrationBlocker[]): string {
|
||||
const words = [...new Set(blockers.flatMap(blockerWords))]
|
||||
return words.length > 0
|
||||
? `This SSH host cannot move yet: ${words.join(', ')}.`
|
||||
: 'This SSH host cannot move yet.'
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
preflightOrcadMigrationExport,
|
||||
type OrcadMigrationPreflightStore
|
||||
} from './ssh-target-orcad-preflight'
|
||||
import { orcadMigrationRefusalReason } from './orcad-migration-refusal-reason'
|
||||
|
||||
export type OrcadMigrationFenceState =
|
||||
| { state: 'none' }
|
||||
@@ -115,7 +116,11 @@ export async function fenceOrcadMigrationSource(args: {
|
||||
const preflight = preflightOrcadMigrationExport(args.store, target.id)
|
||||
if (!preflight.claimable) {
|
||||
return {
|
||||
...refuse('live', 'orcad_migration_preflight_blocked', 'This SSH host cannot move yet.'),
|
||||
...refuse(
|
||||
'live',
|
||||
'orcad_migration_preflight_blocked',
|
||||
orcadMigrationRefusalReason(preflight.blockers)
|
||||
),
|
||||
blockers: preflight.blockers
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import { isExecutionHostAliasForWorktree } from './worktree-execution-host-alias
|
||||
import { folderWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { dedupePaletteWorktrees } from './palette-repo-resolution'
|
||||
import { getResolvedExecutionHostIdForWorktree } from './resolved-worktree-execution-host'
|
||||
import type { WorktreeRuntimeOwnerState } from './worktree-runtime-owner-state'
|
||||
|
||||
export function getPaletteOwnershipWorktreeIds(
|
||||
state: Pick<AppState, 'folderWorkspaces' | 'worktreesByRepo'>
|
||||
@@ -40,15 +42,21 @@ export function findAmbiguousWorktreeIds(
|
||||
}
|
||||
|
||||
export function getActiveExecutionHostIdForWorktree(
|
||||
state: {
|
||||
state: WorktreeRuntimeOwnerState & {
|
||||
activeWorktreeId: string | null
|
||||
activeWorkspaceExecutionHostId?: ExecutionHostId | null
|
||||
},
|
||||
worktreeId: string
|
||||
): ExecutionHostId | undefined {
|
||||
return state.activeWorktreeId === worktreeId
|
||||
? (state.activeWorkspaceExecutionHostId ?? LOCAL_EXECUTION_HOST_ID)
|
||||
: undefined
|
||||
if (state.activeWorktreeId !== worktreeId) {
|
||||
return undefined
|
||||
}
|
||||
// Why: most activations pass no host; a bare 'local' there stamped SSH worktrees' tabs as local.
|
||||
return (
|
||||
state.activeWorkspaceExecutionHostId ??
|
||||
getResolvedExecutionHostIdForWorktree(state, worktreeId) ??
|
||||
LOCAL_EXECUTION_HOST_ID
|
||||
)
|
||||
}
|
||||
|
||||
export function isUnifiedTabOwnedByWorktree(
|
||||
|
||||
@@ -90,6 +90,23 @@ describe('buildWorkspaceSessionPatch', () => {
|
||||
expect(patch).toEqual({ activeTabId: 'tab-2' })
|
||||
})
|
||||
|
||||
it('writes a changed workspace key and host so a refocus replaces the persisted ones', () => {
|
||||
const patch = buildWorkspaceSessionPatch(
|
||||
createSnapshot({
|
||||
activeWorktreeId: 'wt-2',
|
||||
activeWorkspaceKey: 'worktree:wt-2',
|
||||
activeWorkspaceExecutionHostId: null
|
||||
}),
|
||||
['activeWorktreeId', 'activeWorkspaceKey', 'activeWorkspaceExecutionHostId']
|
||||
)
|
||||
|
||||
expect(patch).toEqual({
|
||||
activeWorktreeId: 'wt-2',
|
||||
activeWorkspaceKey: 'worktree:wt-2',
|
||||
activeWorkspaceExecutionHostId: null
|
||||
})
|
||||
})
|
||||
|
||||
it('derives only editor session keys for open file changes', () => {
|
||||
const patch = buildWorkspaceSessionPatch(createSnapshot(), ['openFiles'])
|
||||
|
||||
|
||||
@@ -65,6 +65,13 @@ export function buildWorkspaceSessionPatch(
|
||||
if (changed.has('activeWorktreeId')) {
|
||||
patch.activeWorktreeId = snapshot.activeWorktreeId
|
||||
}
|
||||
// Why: without these the debounced writer kept the first focus's key and host forever.
|
||||
if (changed.has('activeWorkspaceKey')) {
|
||||
patch.activeWorkspaceKey = snapshot.activeWorkspaceKey
|
||||
}
|
||||
if (changed.has('activeWorkspaceExecutionHostId')) {
|
||||
patch.activeWorkspaceExecutionHostId = snapshot.activeWorkspaceExecutionHostId ?? null
|
||||
}
|
||||
if (changed.has('activeTabId')) {
|
||||
patch.activeTabId = snapshot.activeTabId
|
||||
}
|
||||
|
||||
@@ -4,7 +4,13 @@ import { getDefaultUIState } from '../../../../shared/constants'
|
||||
import { buildMobileSessionTabSnapshots } from '../../runtime/sync-runtime-graph'
|
||||
import { closeMobileSessionTabInStore } from '../../runtime/mobile-session-tab-close'
|
||||
import { createTabsSliceMockApi } from './tabs-slice-test-harness'
|
||||
import { createTestStore, makeOpenFile, makeTabGroup, makeUnifiedTab } from './store-test-helpers'
|
||||
import {
|
||||
createTestStore,
|
||||
makeOpenFile,
|
||||
makeTabGroup,
|
||||
makeUnifiedTab,
|
||||
makeWorktree
|
||||
} from './store-test-helpers'
|
||||
|
||||
// Mock sonner (imported by repos.ts)
|
||||
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
|
||||
@@ -74,6 +80,19 @@ describe('TabsSlice', () => {
|
||||
expect(store.getState().createUnifiedTab(WT, 'browser').executionHostId).toBe('local')
|
||||
})
|
||||
|
||||
it("stamps an SSH worktree's own host when activation passed none", () => {
|
||||
store.setState({
|
||||
repos: [
|
||||
{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }
|
||||
],
|
||||
worktreesByRepo: { repo1: [makeWorktree({ id: WT, repoId: 'repo1', hostId: 'ssh:box' })] },
|
||||
activeWorktreeId: WT,
|
||||
activeWorkspaceExecutionHostId: null
|
||||
})
|
||||
|
||||
expect(store.getState().createUnifiedTab(WT, 'browser').executionHostId).toBe('ssh:box')
|
||||
})
|
||||
|
||||
it('activates the newly created tab', () => {
|
||||
const tab1 = store.getState().createUnifiedTab(WT, 'terminal')
|
||||
const tab2 = store.getState().createUnifiedTab(WT, 'terminal')
|
||||
|
||||
Reference in New Issue
Block a user