Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { describe, expect, it } from 'vitest'
import type { ExecutionHostId } from '../../../shared/execution-host'
import type { Repo, Worktree, WorkspaceSessionState } from '../../../shared/types'
import { reconcileWorkspaceSessionWorktreeIds } from './workspace-session-worktree-id-reconciliation'

function repo(id: string, path: string, connectionId?: string): Repo {
return { id, path, displayName: id, badgeColor: '#000', addedAt: 0, connectionId }
}

function worktree(id: string, repoId: string, path: string, hostId?: ExecutionHostId): Worktree {
return {
id,
repoId,
path,
hostId,
displayName: id,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
linkedGitLabMR: null,
linkedGitLabIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
head: '',
branch: '',
isBare: false,
isMainWorktree: true
}
}

function session(oldId: string, newId?: string): WorkspaceSessionState {
return {
activeRepoId: oldId.split('::')[0]!,
activeWorktreeId: oldId,
activeWorkspaceKey: `worktree:${oldId}`,
activeTabId: 'old-tab',
tabsByWorktree: {
[oldId]: [
{
id: 'old-tab',
ptyId: null,
worktreeId: oldId,
title: 'Grok',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
],
...(newId
? {
[newId]: [
{
id: 'new-tab',
ptyId: null,
worktreeId: newId,
title: 'Codex',
customTitle: null,
color: null,
sortOrder: 1,
createdAt: 2
}
]
}
: {})
},
activeTabIdByWorktree: { [oldId]: 'old-tab' },
activeWorktreeIdsOnShutdown: [oldId],
terminalLayoutsByTabId: {
'old-tab': {
root: { type: 'leaf', leafId: 'leaf-old' },
activeLeafId: 'leaf-old',
expandedLeafId: null,
ptyIdsByLeafId: { 'leaf-old': `${oldId}@@daemon-session` }
}
},
sleepingAgentSessionsByPaneKey: {
'old-tab:leaf-old': {
paneKey: 'old-tab:leaf-old',
tabId: 'old-tab',
worktreeId: oldId,
agent: 'grok',
providerSession: null,
prompt: '',
state: 'working',
capturedAt: 1,
updatedAt: 1,
terminalTitle: 'Grok',
origin: 'quit'
}
}
} as unknown as WorkspaceSessionState
}

describe('reconcileWorkspaceSessionWorktreeIds', () => {
it('moves a stale local worktree id to the unique current path match', () => {
const oldId = 'old-repo::D:/Code/orca'
const newId = 'new-repo::D:\\Code\\orca'
const result = reconcileWorkspaceSessionWorktreeIds({
session: session(oldId, newId),
repos: [repo('new-repo', 'D:\\Code\\orca')],
worktreesByRepo: { 'new-repo': [worktree(newId, 'new-repo', 'D:\\Code\\orca')] },
runtimeHostIdByWorkspaceSessionKey: {}
})

expect(result.activeWorktreeId).toBe(newId)
expect(result.activeRepoId).toBe('new-repo')
expect(result.tabsByWorktree[newId]?.map((tab) => tab.id)).toEqual(['old-tab', 'new-tab'])
expect(result.tabsByWorktree[oldId]).toBeUndefined()
expect(result.tabsByWorktree[newId]?.[0]?.worktreeId).toBe(newId)
expect(result.sleepingAgentSessionsByPaneKey?.['old-tab:leaf-old']?.worktreeId).toBe(newId)
expect(result.terminalLayoutsByTabId['old-tab']?.ptyIdsByLeafId?.['leaf-old']).toBe(
`${oldId}@@daemon-session`
)
})

it('does not guess when multiple worktrees share a host and normalized path', () => {
const oldId = 'old::/repo'
const first = 'one::/repo'
const second = 'two::/repo'
const original = session(oldId)
const result = reconcileWorkspaceSessionWorktreeIds({
session: original,
repos: [repo('one', '/repo'), repo('two', '/repo')],
worktreesByRepo: {
one: [worktree(first, 'one', '/repo')],
two: [worktree(second, 'two', '/repo')]
},
runtimeHostIdByWorkspaceSessionKey: {}
})

expect(result).toBe(original)
})

it('only uses the missing-repo local fallback for a unique local path match', () => {
const oldId = 'missing::/repo'
const localId = 'local-repo::/repo'
const remoteId = 'remote-repo::/repo'
const result = reconcileWorkspaceSessionWorktreeIds({
session: session(oldId),
repos: [repo('local-repo', '/repo'), repo('remote-repo', '/repo')],
worktreesByRepo: {
'local-repo': [worktree(localId, 'local-repo', '/repo')],
'remote-repo': [worktree(remoteId, 'remote-repo', '/repo', 'ssh:builder')]
},
runtimeHostIdByWorkspaceSessionKey: {}
})

expect(result.activeWorktreeId).toBe(localId)
expect(result.activeRepoId).toBe('local-repo')
expect(result.tabsByWorktree[remoteId]).toBeUndefined()
})

it('does not cross execution hosts when paths are identical', () => {
const oldId = 'old::/repo'
const remoteId = 'remote::/repo'
const original = session(oldId)
const result = reconcileWorkspaceSessionWorktreeIds({
session: original,
repos: [repo('remote', '/repo')],
worktreesByRepo: {
remote: [worktree(remoteId, 'remote', '/repo', 'runtime:remote-host')]
},
runtimeHostIdByWorkspaceSessionKey: {}
})

expect(result).toBe(original)
})

it('matches an SSH repo connection id to its canonical worktree host id', () => {
const oldId = 'old::/repo'
const newId = 'new::/repo'
const result = reconcileWorkspaceSessionWorktreeIds({
session: session(oldId),
repos: [repo('old', '/repo', 'builder'), repo('new', '/repo', 'builder')],
worktreesByRepo: {
new: [worktree(newId, 'new', '/repo', 'ssh:builder')]
},
runtimeHostIdByWorkspaceSessionKey: {}
})

expect(result.activeWorktreeId).toBe(newId)
expect(result.tabsByWorktree[newId]?.[0]?.worktreeId).toBe(newId)
})
})
117 changes: 117 additions & 0 deletions src/renderer/src/lib/workspace-session-worktree-id-reconciliation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path'
import { getRepoExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host'
import type { Repo, Worktree, WorkspaceSessionState } from '../../../shared/types'
import { splitWorktreeId } from '../../../shared/worktree-id'
import { worktreeWorkspaceKey } from '../../../shared/workspace-scope'

type ReconciliationInput = {
session: WorkspaceSessionState
repos: readonly Repo[]
worktreesByRepo: Record<string, Worktree[]>
runtimeHostIdByWorkspaceSessionKey: Record<string, ExecutionHostId>
}

function repoHostKey(repo: Repo | undefined): string {
return repo ? getRepoExecutionHostId(repo) : 'local'
}

function worktreeHostKey(worktree: Worktree, reposById: Map<string, Repo>): string {
return worktree.hostId ?? repoHostKey(reposById.get(worktree.repoId))
}

function mergeCollision(left: unknown, right: unknown): unknown {
if (Array.isArray(left) && Array.isArray(right)) {
return [...left, ...right]
}
if (
left !== null &&
right !== null &&
typeof left === 'object' &&
typeof right === 'object' &&
!Array.isArray(left) &&
!Array.isArray(right)
) {
return { ...(left as Record<string, unknown>), ...(right as Record<string, unknown>) }
}
return right
}

function remapSerializableValue(value: unknown, aliases: ReadonlyMap<string, string>): unknown {
if (typeof value === 'string') {
const direct = aliases.get(value)
if (direct) {
return direct
}
for (const [oldId, newId] of aliases) {
if (value === worktreeWorkspaceKey(oldId)) {
return worktreeWorkspaceKey(newId)
}
}
return value
}
if (Array.isArray(value)) {
return value.map((entry) => remapSerializableValue(entry, aliases))
}
if (value === null || typeof value !== 'object') {
return value
}
const remapped: Record<string, unknown> = {}
for (const [key, entry] of Object.entries(value)) {
const remappedKey = aliases.get(key) ?? key
const remappedEntry = remapSerializableValue(entry, aliases)
remapped[remappedKey] =
remappedKey in remapped ? mergeCollision(remapped[remappedKey], remappedEntry) : remappedEntry
}
return remapped
}

export function reconcileWorkspaceSessionWorktreeIds({
session,
repos,
worktreesByRepo,
runtimeHostIdByWorkspaceSessionKey
}: ReconciliationInput): WorkspaceSessionState {
const currentWorktrees = Object.values(worktreesByRepo).flat()
const currentIds = new Set(currentWorktrees.map((worktree) => worktree.id))
const reposById = new Map(repos.map((repo) => [repo.id, repo]))
const aliases = new Map<string, string>()

for (const oldId of Object.keys(session.tabsByWorktree)) {
if (currentIds.has(oldId)) {
continue
}
const parsed = splitWorktreeId(oldId)
if (!parsed?.worktreePath) {
continue
}
const explicitHost =
runtimeHostIdByWorkspaceSessionKey[worktreeWorkspaceKey(oldId)] ??
runtimeHostIdByWorkspaceSessionKey[oldId]
const oldRepo = reposById.get(parsed.repoId)
const oldHost = explicitHost ?? (oldRepo ? repoHostKey(oldRepo) : 'local')
const oldPath = normalizeRuntimePathForComparison(parsed.worktreePath)
const matches = currentWorktrees.filter(
(worktree) =>
worktreeHostKey(worktree, reposById) === oldHost &&
normalizeRuntimePathForComparison(worktree.path) === oldPath
)
if (matches.length === 1 && matches[0]?.id !== oldId) {
aliases.set(oldId, matches[0]!.id)
}
}

if (aliases.size === 0) {
return session
}
// Why: daemon PTY ids merely contain the old worktree id as a prefix; only
// exact session keys/values are remapped so the live daemon can still attach.
const reconciled = remapSerializableValue(session, aliases) as WorkspaceSessionState
const activeWorktreeAlias = session.activeWorktreeId
? aliases.get(session.activeWorktreeId)
: undefined
if (activeWorktreeAlias) {
reconciled.activeRepoId =
splitWorktreeId(activeWorktreeAlias)?.repoId ?? reconciled.activeRepoId
}
return reconciled
}
9 changes: 8 additions & 1 deletion src/renderer/src/store/slices/terminals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ import {
removeSleepingRecordsReplacedByManualWorktreeSleep,
type AgentStatusWorktreeShutdownReason
} from './agent-status'
import { reconcileWorkspaceSessionWorktreeIds } from '@/lib/workspace-session-worktree-id-reconciliation'

function getNextTerminalOrdinal(tabs: TerminalTab[]): number {
const usedOrdinals = new Set<number>()
Expand Down Expand Up @@ -3018,13 +3019,19 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
return data
},

hydrateWorkspaceSession: (session, options) => {
hydrateWorkspaceSession: (persistedSession, options) => {
set((s) => {
const runtimeSessionPlaceholders = buildRuntimeSessionPlaceholders({
repos: s.repos,
runtimeHostIdByWorkspaceSessionKey: options?.runtimeHostIdByWorkspaceSessionKey ?? {},
worktreesByRepo: s.worktreesByRepo
})
const session = reconcileWorkspaceSessionWorktreeIds({
session: persistedSession,
repos: runtimeSessionPlaceholders.repos,
worktreesByRepo: runtimeSessionPlaceholders.worktreesByRepo,
runtimeHostIdByWorkspaceSessionKey: options?.runtimeHostIdByWorkspaceSessionKey ?? {}
})
const validWorktreeIds = new Set(
Object.values(runtimeSessionPlaceholders.worktreesByRepo)
.flat()
Expand Down
Loading