Speed up startup with deferred remote catalogs (#7087)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
014fd3cf26
commit
bc84f4b04b
|
|
@ -112,10 +112,11 @@ import {
|
|||
} from './lib/workspace-session'
|
||||
import { createSessionWriteSubscriber } from './lib/session-write-subscriber'
|
||||
import {
|
||||
fetchWorkspaceSessionFromHosts,
|
||||
fetchWorkspaceSessionWithRuntimeHostOwners,
|
||||
patchWorkspaceSessionByHost,
|
||||
persistWorkspaceSessionByHostSync
|
||||
} from './lib/workspace-session-host-persistence'
|
||||
import { collectFolderWorkspaceKeysFromSession } from './lib/workspace-session-hydration-keys'
|
||||
import {
|
||||
getStartupErrorFallbackUI,
|
||||
hydratePersistedUIAfterStartupRead
|
||||
|
|
@ -158,6 +159,7 @@ import {
|
|||
type KeybindingContext,
|
||||
type PhysicalModifierToken
|
||||
} from '../../shared/keybindings'
|
||||
import { toRuntimeExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
|
||||
import {
|
||||
ModifierDoubleTapDetector,
|
||||
toModifierDoubleTapEvent
|
||||
|
|
@ -180,6 +182,17 @@ const hasCustomTitleBar = shouldRenderDesktopWindowChrome({
|
|||
isWebClient: isPairedWebClientWindow()
|
||||
})
|
||||
|
||||
async function listRuntimeSessionHostIdsForStartup(): Promise<ExecutionHostId[]> {
|
||||
try {
|
||||
return (await window.api.runtimeEnvironments.list()).map((environment) =>
|
||||
toRuntimeExecutionHostId(environment.id)
|
||||
)
|
||||
} catch (err) {
|
||||
console.warn('Failed to list runtime session hosts for startup:', err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function getKeybindingContext(target: EventTarget | null): KeybindingContext {
|
||||
return target instanceof HTMLElement && target.classList.contains('xterm-helper-textarea')
|
||||
? 'terminal'
|
||||
|
|
@ -855,17 +868,15 @@ function App(): React.JSX.Element {
|
|||
// Load settings first so a persisted remote runtime does not boot against
|
||||
// the local filesystem and then hydrate stale local workspace state.
|
||||
await timeRendererStartupStep('fetch-settings', () => actions.fetchSettings())
|
||||
// Why: these three reads are main-side store/file reads with no
|
||||
// dependency on anything fetched below, so start them now and await
|
||||
// them at their original positions — the round-trips overlap the
|
||||
// repo/worktree scans instead of queuing after them. Browser session
|
||||
// profiles are deliberately NOT started early: on a remote runtime
|
||||
// they route through a runtime RPC that may not be connected this
|
||||
// early, and a failed fetch clears the profile list. The floating
|
||||
// .catch marks the rejection handled if an earlier awaited step
|
||||
// throws first; each await still rethrows its own failure.
|
||||
const uiGetPromise = timeRendererStartupStep('ui-get', () => window.api.ui.get())
|
||||
uiGetPromise.catch(() => {})
|
||||
// Why: keybindings + onboarding are main-side reads with no dependency
|
||||
// on the catalog/session steps below, so start them now and await them
|
||||
// at their original positions — the round-trips overlap the local
|
||||
// catalog scans instead of queuing after them. Browser session profiles
|
||||
// are deliberately NOT started early: on a remote runtime they route
|
||||
// through a runtime RPC that may not be connected this early, and a
|
||||
// failed fetch clears the profile list. The floating .catch marks the
|
||||
// rejection handled if an earlier awaited step throws first; each await
|
||||
// still rethrows its own failure.
|
||||
const keybindingsPromise = timeRendererStartupStep('fetch-keybindings', () =>
|
||||
actions.fetchKeybindings()
|
||||
)
|
||||
|
|
@ -874,36 +885,11 @@ function App(): React.JSX.Element {
|
|||
window.api.onboarding.get()
|
||||
)
|
||||
onboardingPromise.catch(() => {})
|
||||
// Why: load local + every configured runtime environment (not just the
|
||||
// active one) so a cold start that restored a remote workspace doesn't
|
||||
// hide local repos. The sidebar "All hosts" scope then shows them all.
|
||||
await timeRendererStartupStep('fetch-repos', () => actions.fetchReposForAllHosts())
|
||||
// Why: project-groups/folder-workspaces read neither the repos store nor
|
||||
// worktrees, so once repos land they can overlap the per-repo
|
||||
// `git worktree list` fan-out (#7225) instead of queuing ahead of it — a
|
||||
// slow remote host's 15s scope RPCs no longer block the scan. folders
|
||||
// still follow project-groups (they read projectGroups); all settle
|
||||
// before the hydrate steps below.
|
||||
const projectScopeChain = (async () => {
|
||||
await timeRendererStartupStep('fetch-project-groups', () =>
|
||||
actions.fetchProjectGroupsForAllHosts()
|
||||
)
|
||||
await timeRendererStartupStep('fetch-folder-workspaces', () =>
|
||||
actions.fetchFolderWorkspacesForAllHosts()
|
||||
)
|
||||
})()
|
||||
// Why: worktrees + lineage both fan out `git worktree list` per repo on
|
||||
// the main process. Running them concurrently lets it share one
|
||||
// in-flight scan per repo instead of paying the process-spawn fan-out
|
||||
// twice back-to-back — the dominant renderer-chain cost on Windows
|
||||
// (issue #7225). Lineage only reads settings + its own slice, so it
|
||||
// does not depend on the worktrees fetch having landed.
|
||||
await Promise.all([
|
||||
projectScopeChain,
|
||||
timeRendererStartupStep('fetch-worktrees', () => actions.fetchAllWorktrees()),
|
||||
timeRendererStartupStep('fetch-worktree-lineage', () => actions.fetchWorktreeLineage())
|
||||
])
|
||||
const persistedUI = await uiGetPromise
|
||||
// Why: hydrate persisted UI immediately after ui.get() so first paint
|
||||
// reflects saved view settings before the catalog scans below. ui.get()
|
||||
// is awaited (not overlapped) because the hydrate must land before the
|
||||
// local-first catalog/session steps run.
|
||||
const persistedUI = await timeRendererStartupStep('ui-get', () => window.api.ui.get())
|
||||
uiHydrated = timeRendererStartupSyncStep('hydrate-persisted-ui', () =>
|
||||
hydratePersistedUIAfterStartupRead({
|
||||
persistedUI,
|
||||
|
|
@ -911,20 +897,49 @@ function App(): React.JSX.Element {
|
|||
hydratePersistedUI: actions.hydratePersistedUI
|
||||
})
|
||||
)
|
||||
const startupRuntimeHostIds = await timeRendererStartupStep(
|
||||
'list-runtime-session-hosts',
|
||||
listRuntimeSessionHostIdsForStartup
|
||||
)
|
||||
// Why: first paint needs local data and persisted view settings, but
|
||||
// saved remote runtimes can spend the full connect timeout. Load only
|
||||
// the local catalog here; remotes refresh after hydration below.
|
||||
await timeRendererStartupStep('fetch-repos-local', () =>
|
||||
actions.fetchReposForAllHosts({ remoteHosts: 'skip' })
|
||||
)
|
||||
await timeRendererStartupStep('fetch-project-groups-local', () =>
|
||||
actions.fetchProjectGroupsForAllHosts({ remoteHosts: 'skip' })
|
||||
)
|
||||
await timeRendererStartupStep('fetch-folder-workspaces-local', () =>
|
||||
actions.fetchFolderWorkspacesForAllHosts({ remoteHosts: 'skip' })
|
||||
)
|
||||
await timeRendererStartupStep('fetch-worktrees', () =>
|
||||
actions.fetchAllWorktrees({ hydrationPurge: 'defer' })
|
||||
)
|
||||
// Why: runtime-owned worktree slices live in per-host partitions.
|
||||
// Repos were fetched above, so the known runtime hosts are derivable
|
||||
// here; merge their slices into the unified session the hydrators
|
||||
// expect. An unreadable host partition is skipped (fail-soft).
|
||||
const session = await timeRendererStartupStep('session-get', () =>
|
||||
fetchWorkspaceSessionFromHosts(window.api.session, useAppStore.getState().repos)
|
||||
// Remote catalogs now load after first paint, so include saved runtime
|
||||
// host ids from local settings to restore their persisted session slices
|
||||
// without waiting on network reachability. Unreadable partitions skip.
|
||||
const sessionRead = await timeRendererStartupStep('session-get', () =>
|
||||
fetchWorkspaceSessionWithRuntimeHostOwners(
|
||||
window.api.session,
|
||||
useAppStore.getState().repos,
|
||||
startupRuntimeHostIds
|
||||
)
|
||||
)
|
||||
await keybindingsPromise
|
||||
if (!cancelled) {
|
||||
const sessionHydrationOptions = {
|
||||
additionalValidWorkspaceKeys: collectFolderWorkspaceKeysFromSession(sessionRead.session)
|
||||
}
|
||||
timeRendererStartupSyncStep('hydrate-session-stores', () => {
|
||||
actions.hydrateWorkspaceSession(session)
|
||||
actions.hydrateTabsSession(session)
|
||||
actions.hydrateEditorSession(session)
|
||||
actions.hydrateBrowserSession(session)
|
||||
actions.hydrateWorkspaceSession(sessionRead.session, {
|
||||
...sessionHydrationOptions,
|
||||
runtimeHostIdByWorkspaceSessionKey: sessionRead.runtimeHostIdByWorkspaceSessionKey
|
||||
})
|
||||
actions.hydrateTabsSession(sessionRead.session, sessionHydrationOptions)
|
||||
actions.hydrateEditorSession(sessionRead.session, sessionHydrationOptions)
|
||||
actions.hydrateBrowserSession(sessionRead.session, sessionHydrationOptions)
|
||||
})
|
||||
// Why: prune lastVisitedAtByWorktreeId entries whose worktrees
|
||||
// no longer exist. Must run AFTER hydration — before this point,
|
||||
|
|
@ -952,7 +967,7 @@ function App(): React.JSX.Element {
|
|||
// tabs through pty.attach on the relay. Passphrase-protected targets
|
||||
// are deferred to tab focus to avoid stacking credential dialogs at
|
||||
// startup before the user has context.
|
||||
const connectionIds = session.activeConnectionIdsAtShutdown ?? []
|
||||
const connectionIds = sessionRead.session.activeConnectionIdsAtShutdown ?? []
|
||||
if (connectionIds.length > 0) {
|
||||
try {
|
||||
const SSH_RECONNECT_TIMEOUT_MS = 15_000
|
||||
|
|
@ -1064,6 +1079,23 @@ function App(): React.JSX.Element {
|
|||
logRendererStartupDiagnostic('startup-hydration-done', {
|
||||
durationMs: Math.round(performance.now() - startupStartedAt)
|
||||
})
|
||||
void (async () => {
|
||||
try {
|
||||
await timeRendererStartupStep('remote-catalog-refresh', async () => {
|
||||
await actions.fetchReposForAllHosts()
|
||||
await actions.fetchProjectGroupsForAllHosts()
|
||||
await actions.fetchFolderWorkspacesForAllHosts()
|
||||
})
|
||||
if (!cancelled) {
|
||||
await timeRendererStartupStep('remote-worktree-refresh', async () => {
|
||||
await actions.fetchAllWorktrees()
|
||||
await actions.fetchWorktreeLineage()
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Remote startup catalog refresh failed:', err)
|
||||
}
|
||||
})()
|
||||
}
|
||||
} catch (error) {
|
||||
// Why (issue #1158): previously this catch called hydrateWorkspaceSession
|
||||
|
|
|
|||
|
|
@ -3,41 +3,64 @@ import { join } from 'node:path'
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('renderer startup runtime routing', () => {
|
||||
it('loads settings before repo and worktree hydration', () => {
|
||||
it('hydrates persisted UI before local catalog and worktree hydration', () => {
|
||||
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
|
||||
const startupBlockStart = source.indexOf('void (async () => {')
|
||||
const startupBlockEnd = source.indexOf('const persistedUI = await uiGetPromise')
|
||||
const startupBlockEnd = source.indexOf("timeRendererStartupStep('session-get'")
|
||||
const startupBlock = source.slice(startupBlockStart, startupBlockEnd)
|
||||
|
||||
const settingsIndex = startupBlock.indexOf('actions.fetchSettings()')
|
||||
expect(settingsIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(settingsIndex).toBeLessThan(startupBlock.indexOf('actions.fetchReposForAllHosts()'))
|
||||
expect(settingsIndex).toBeLessThan(startupBlock.indexOf('actions.fetchAllWorktrees()'))
|
||||
})
|
||||
|
||||
it('overlaps sidebar scope loads with worktree hydration before session hydration', () => {
|
||||
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
|
||||
const startupBlockStart = source.indexOf('void (async () => {')
|
||||
const startupBlockEnd = source.indexOf('const persistedUI = await uiGetPromise')
|
||||
const startupBlock = source.slice(startupBlockStart, startupBlockEnd)
|
||||
|
||||
const reposIndex = startupBlock.indexOf('actions.fetchReposForAllHosts()')
|
||||
const scopeChainIndex = startupBlock.indexOf('const projectScopeChain = (async () => {')
|
||||
const projectGroupsIndex = startupBlock.indexOf('actions.fetchProjectGroupsForAllHosts()')
|
||||
const folderWorkspacesIndex = startupBlock.indexOf('actions.fetchFolderWorkspacesForAllHosts()')
|
||||
const promiseAllIndex = startupBlock.indexOf('await Promise.all([')
|
||||
const awaitedScopeChainIndex = startupBlock.indexOf('projectScopeChain', promiseAllIndex)
|
||||
const worktreesIndex = startupBlock.indexOf('actions.fetchAllWorktrees()')
|
||||
const uiGetIndex = startupBlock.indexOf("timeRendererStartupStep('ui-get'")
|
||||
const hydrateUiIndex = startupBlock.indexOf(
|
||||
"timeRendererStartupSyncStep('hydrate-persisted-ui'"
|
||||
)
|
||||
const localReposIndex = startupBlock.indexOf(
|
||||
"actions.fetchReposForAllHosts({ remoteHosts: 'skip' })"
|
||||
)
|
||||
const localGroupsIndex = startupBlock.indexOf(
|
||||
"actions.fetchProjectGroupsForAllHosts({ remoteHosts: 'skip' })"
|
||||
)
|
||||
const localFoldersIndex = startupBlock.indexOf(
|
||||
"actions.fetchFolderWorkspacesForAllHosts({ remoteHosts: 'skip' })"
|
||||
)
|
||||
const localWorktreesIndex = startupBlock.indexOf(
|
||||
"actions.fetchAllWorktrees({ hydrationPurge: 'defer' })"
|
||||
)
|
||||
const lineageIndex = startupBlock.indexOf('actions.fetchWorktreeLineage()')
|
||||
|
||||
expect(reposIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(scopeChainIndex).toBeGreaterThan(reposIndex)
|
||||
expect(projectGroupsIndex).toBeGreaterThan(scopeChainIndex)
|
||||
expect(folderWorkspacesIndex).toBeGreaterThan(projectGroupsIndex)
|
||||
expect(promiseAllIndex).toBeGreaterThan(scopeChainIndex)
|
||||
expect(awaitedScopeChainIndex).toBeGreaterThan(promiseAllIndex)
|
||||
expect(worktreesIndex).toBeGreaterThan(promiseAllIndex)
|
||||
expect(lineageIndex).toBeGreaterThan(promiseAllIndex)
|
||||
expect(settingsIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(startupBlockEnd).toBeGreaterThan(startupBlockStart)
|
||||
expect(settingsIndex).toBeLessThan(uiGetIndex)
|
||||
expect(uiGetIndex).toBeLessThan(hydrateUiIndex)
|
||||
expect(hydrateUiIndex).toBeLessThan(localReposIndex)
|
||||
expect(localReposIndex).toBeLessThan(localGroupsIndex)
|
||||
expect(localGroupsIndex).toBeLessThan(localFoldersIndex)
|
||||
expect(localFoldersIndex).toBeLessThan(localWorktreesIndex)
|
||||
expect(lineageIndex).toBe(-1)
|
||||
})
|
||||
|
||||
it('refreshes remote catalogs after startup hydration succeeds', () => {
|
||||
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
|
||||
const hydrationDoneIndex = source.indexOf(
|
||||
"logRendererStartupDiagnostic('startup-hydration-done'"
|
||||
)
|
||||
const remoteCatalogIndex = source.indexOf("timeRendererStartupStep('remote-catalog-refresh'")
|
||||
const remoteWorktreeIndex = source.indexOf("timeRendererStartupStep('remote-worktree-refresh'")
|
||||
const lineageIndex = source.indexOf('actions.fetchWorktreeLineage()')
|
||||
|
||||
expect(hydrationDoneIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(hydrationDoneIndex).toBeLessThan(remoteCatalogIndex)
|
||||
expect(remoteCatalogIndex).toBeLessThan(remoteWorktreeIndex)
|
||||
expect(remoteWorktreeIndex).toBeLessThan(lineageIndex)
|
||||
expect(source.slice(remoteCatalogIndex, remoteWorktreeIndex)).toContain(
|
||||
'actions.fetchReposForAllHosts()'
|
||||
)
|
||||
expect(source.slice(remoteCatalogIndex, remoteWorktreeIndex)).toContain(
|
||||
'actions.fetchProjectGroupsForAllHosts()'
|
||||
)
|
||||
expect(source.slice(remoteCatalogIndex, remoteWorktreeIndex)).toContain(
|
||||
'actions.fetchFolderWorkspacesForAllHosts()'
|
||||
)
|
||||
})
|
||||
|
||||
it('waits for first-window startup services before terminal reconnect', () => {
|
||||
|
|
|
|||
|
|
@ -112,6 +112,13 @@ export function filterSetupScriptPromptDismissalsToValidRepos(
|
|||
value: unknown,
|
||||
validRepoIds: Set<string>
|
||||
): string[] {
|
||||
return sanitizeSetupScriptPromptDismissals(value).filter((entry) => {
|
||||
const repoId = entry.slice(SETUP_SCRIPT_PROMPT_DISMISSAL_PREFIX.length)
|
||||
return validRepoIds.has(repoId)
|
||||
})
|
||||
}
|
||||
|
||||
export function sanitizeSetupScriptPromptDismissals(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
|
|
@ -121,8 +128,7 @@ export function filterSetupScriptPromptDismissalsToValidRepos(
|
|||
if (typeof entry !== 'string' || !entry.startsWith(SETUP_SCRIPT_PROMPT_DISMISSAL_PREFIX)) {
|
||||
continue
|
||||
}
|
||||
const repoId = entry.slice(SETUP_SCRIPT_PROMPT_DISMISSAL_PREFIX.length)
|
||||
if (validRepoIds.has(repoId) && !next.includes(entry)) {
|
||||
if (!next.includes(entry)) {
|
||||
next.push(entry)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,386 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultWorkspaceSession } from '../../../shared/constants'
|
||||
import type { WorkspaceSessionState } from '../../../shared/types'
|
||||
import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import {
|
||||
buildHostIdByWorktreeId,
|
||||
fetchWorkspaceSessionFromHosts,
|
||||
fetchWorkspaceSessionWithRuntimeHostOwners,
|
||||
patchWorkspaceSessionByHost
|
||||
} from './workspace-session-host-persistence'
|
||||
|
||||
describe('fetchWorkspaceSessionFromHosts', () => {
|
||||
it('reads saved runtime host partitions before runtime repos are loaded', async () => {
|
||||
const worktreeId = 'remote-repo::/srv/remote-wt'
|
||||
const localSession: WorkspaceSessionState = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
activeWorktreeId: 'local-wt'
|
||||
}
|
||||
const remoteSession: WorkspaceSessionState = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: {
|
||||
[worktreeId]: [
|
||||
{
|
||||
id: 'remote-tab',
|
||||
ptyId: null,
|
||||
worktreeId,
|
||||
title: 'Remote',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
const get = vi.fn(async (hostId?: string) =>
|
||||
hostId === 'runtime:env-1' ? remoteSession : localSession
|
||||
)
|
||||
|
||||
const session = await fetchWorkspaceSessionFromHosts({ get }, [], ['runtime:env-1'])
|
||||
|
||||
expect(get).toHaveBeenCalledWith()
|
||||
expect(get).toHaveBeenCalledWith('runtime:env-1')
|
||||
expect(session.activeWorktreeId).toBe('local-wt')
|
||||
expect(session.tabsByWorktree[worktreeId]).toEqual(remoteSession.tabsByWorktree[worktreeId])
|
||||
})
|
||||
|
||||
it('returns runtime owners for worktrees loaded from runtime host partitions', async () => {
|
||||
const worktreeId = 'remote-repo::/srv/remote-wt'
|
||||
const get = vi.fn(async (hostId?: string): Promise<WorkspaceSessionState> => {
|
||||
if (hostId === 'runtime:env-1') {
|
||||
return {
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: {
|
||||
[worktreeId]: [
|
||||
{
|
||||
id: 'remote-tab',
|
||||
ptyId: null,
|
||||
worktreeId,
|
||||
title: 'Remote',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
return getDefaultWorkspaceSession()
|
||||
})
|
||||
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get }, [], ['runtime:env-1'])
|
||||
|
||||
expect(read.session.tabsByWorktree[worktreeId]).toHaveLength(1)
|
||||
expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({ [worktreeId]: 'runtime:env-1' })
|
||||
})
|
||||
|
||||
it('normalizes canonical worktree session keys in runtime owner maps', async () => {
|
||||
const worktreeId = 'remote-repo::/srv/remote-wt'
|
||||
const workspaceKey = worktreeWorkspaceKey(worktreeId)
|
||||
const get = vi.fn(async (hostId?: string): Promise<WorkspaceSessionState> => {
|
||||
if (hostId === 'runtime:env-1') {
|
||||
return {
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: {
|
||||
[workspaceKey]: [
|
||||
{
|
||||
id: 'remote-tab',
|
||||
ptyId: null,
|
||||
worktreeId,
|
||||
title: 'Remote',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
return getDefaultWorkspaceSession()
|
||||
})
|
||||
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get }, [], ['runtime:env-1'])
|
||||
|
||||
expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({ [worktreeId]: 'runtime:env-1' })
|
||||
})
|
||||
|
||||
it('returns runtime owners for folder workspace session keys', async () => {
|
||||
const folderKey = folderWorkspaceKey('folder-1')
|
||||
const get = vi.fn(async (hostId?: string): Promise<WorkspaceSessionState> => {
|
||||
if (hostId === 'runtime:env-1') {
|
||||
return {
|
||||
...getDefaultWorkspaceSession(),
|
||||
activeWorkspaceKey: folderKey,
|
||||
tabsByWorktree: {
|
||||
[folderKey]: [
|
||||
{
|
||||
id: 'remote-folder-tab',
|
||||
ptyId: null,
|
||||
worktreeId: folderKey,
|
||||
title: 'Remote folder',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
return getDefaultWorkspaceSession()
|
||||
})
|
||||
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get }, [], ['runtime:env-1'])
|
||||
|
||||
expect(read.session.tabsByWorktree[folderKey]).toHaveLength(1)
|
||||
expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({ [folderKey]: 'runtime:env-1' })
|
||||
})
|
||||
|
||||
it('returns runtime owners for sleeping-agent-only runtime worktrees', async () => {
|
||||
const worktreeId = 'remote-repo::/srv/sleeping-wt'
|
||||
const get = vi.fn(async (hostId?: string): Promise<WorkspaceSessionState> => {
|
||||
if (hostId === 'runtime:env-1') {
|
||||
return {
|
||||
...getDefaultWorkspaceSession(),
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
'remote-tab:leaf-1': {
|
||||
paneKey: 'remote-tab:leaf-1',
|
||||
tabId: 'remote-tab',
|
||||
worktreeId,
|
||||
agent: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'codex-session-1' },
|
||||
prompt: 'finish the task',
|
||||
state: 'working',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return getDefaultWorkspaceSession()
|
||||
})
|
||||
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get }, [], ['runtime:env-1'])
|
||||
|
||||
expect(read.session.sleepingAgentSessionsByPaneKey?.['remote-tab:leaf-1']?.worktreeId).toBe(
|
||||
worktreeId
|
||||
)
|
||||
expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({ [worktreeId]: 'runtime:env-1' })
|
||||
})
|
||||
|
||||
it('routes restored runtime folder workspace patches back to the runtime host', async () => {
|
||||
const folderKey = folderWorkspaceKey('folder-1')
|
||||
const patch = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await patchWorkspaceSessionByHost(
|
||||
{ get: vi.fn(), patch, setSync: vi.fn() },
|
||||
{
|
||||
tabsByWorktree: {
|
||||
[folderKey]: [
|
||||
{
|
||||
id: 'remote-folder-tab',
|
||||
ptyId: null,
|
||||
worktreeId: folderKey,
|
||||
title: 'Remote folder',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
repos: [],
|
||||
worktreesByRepo: {},
|
||||
restoredRuntimeHostIdByWorkspaceSessionKey: { [folderKey]: 'runtime:env-1' }
|
||||
}
|
||||
)
|
||||
|
||||
expect(patch).toHaveBeenCalledWith(expect.objectContaining({ tabsByWorktree: {} }))
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tabsByWorktree: expect.objectContaining({
|
||||
[folderKey]: expect.any(Array)
|
||||
})
|
||||
}),
|
||||
'runtime:env-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps catalog-known local folder workspace patches local over stale restored owners', async () => {
|
||||
const folderKey = folderWorkspaceKey('folder-1')
|
||||
const patch = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await patchWorkspaceSessionByHost(
|
||||
{ get: vi.fn(), patch, setSync: vi.fn() },
|
||||
{
|
||||
tabsByWorktree: {
|
||||
[folderKey]: [
|
||||
{
|
||||
id: 'local-folder-tab',
|
||||
ptyId: null,
|
||||
worktreeId: folderKey,
|
||||
title: 'Local folder',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
repos: [],
|
||||
folderWorkspaces: [{ id: 'folder-1', projectGroupId: 'group-1' }],
|
||||
projectGroups: [{ id: 'group-1', executionHostId: 'local' }],
|
||||
worktreesByRepo: {},
|
||||
restoredRuntimeHostIdByWorkspaceSessionKey: { [folderKey]: 'runtime:stale-env' }
|
||||
}
|
||||
)
|
||||
|
||||
expect(patch).toHaveBeenCalledTimes(1)
|
||||
expect(patch.mock.calls[0][0]).toEqual(
|
||||
expect.objectContaining({
|
||||
tabsByWorktree: expect.objectContaining({
|
||||
[folderKey]: expect.any(Array)
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(patch.mock.calls[0][1]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('routes placeholder-owned runtime worktree patches back to the runtime host', async () => {
|
||||
const worktreeId = 'remote-repo::/srv/remote-wt'
|
||||
const patch = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await patchWorkspaceSessionByHost(
|
||||
{ get: vi.fn(), patch, setSync: vi.fn() },
|
||||
{
|
||||
tabsByWorktree: {
|
||||
[worktreeId]: [
|
||||
{
|
||||
id: 'remote-tab',
|
||||
ptyId: null,
|
||||
worktreeId,
|
||||
title: 'Remote',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
repos: [{ id: 'remote-repo', connectionId: null, executionHostId: 'runtime:env-1' }],
|
||||
worktreesByRepo: { 'remote-repo': [{ id: worktreeId, repoId: 'remote-repo' }] }
|
||||
}
|
||||
)
|
||||
|
||||
expect(patch).toHaveBeenCalledWith(expect.objectContaining({ tabsByWorktree: {} }))
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tabsByWorktree: expect.objectContaining({
|
||||
[worktreeId]: expect.any(Array)
|
||||
})
|
||||
}),
|
||||
'runtime:env-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps same-id local repo worktrees in the local partition', async () => {
|
||||
const localWorktreeId = 'same-repo::/Users/me/project'
|
||||
const remoteWorktreeId = 'same-repo::/srv/project'
|
||||
const patch = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await patchWorkspaceSessionByHost(
|
||||
{ get: vi.fn(), patch, setSync: vi.fn() },
|
||||
{
|
||||
tabsByWorktree: {
|
||||
[localWorktreeId]: [
|
||||
{
|
||||
id: 'local-tab',
|
||||
ptyId: null,
|
||||
worktreeId: localWorktreeId,
|
||||
title: 'Local',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
],
|
||||
[remoteWorktreeId]: [
|
||||
{
|
||||
id: 'remote-tab',
|
||||
ptyId: null,
|
||||
worktreeId: remoteWorktreeId,
|
||||
title: 'Remote',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
repos: [
|
||||
{ id: 'same-repo', connectionId: null, executionHostId: 'local' },
|
||||
{ id: 'same-repo', connectionId: null, executionHostId: 'runtime:env-1' }
|
||||
],
|
||||
worktreesByRepo: {
|
||||
'same-repo': [
|
||||
{ id: localWorktreeId, repoId: 'same-repo' },
|
||||
{ id: remoteWorktreeId, repoId: 'same-repo', hostId: 'runtime:env-1' }
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tabsByWorktree: {
|
||||
[localWorktreeId]: expect.any(Array)
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tabsByWorktree: expect.objectContaining({
|
||||
[remoteWorktreeId]: expect.any(Array)
|
||||
})
|
||||
}),
|
||||
'runtime:env-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults duplicate repo ids to local when the worktree has no host metadata', () => {
|
||||
const owner = buildHostIdByWorktreeId({
|
||||
repos: [
|
||||
{ id: 'same-repo', connectionId: null, executionHostId: 'local' },
|
||||
{ id: 'same-repo', connectionId: null, executionHostId: 'runtime:env-1' }
|
||||
],
|
||||
worktreesByRepo: {
|
||||
'same-repo': [{ id: 'same-repo::/local-only', repoId: 'same-repo' }]
|
||||
}
|
||||
})
|
||||
|
||||
expect(owner('same-repo::/local-only')).toBe('local')
|
||||
})
|
||||
|
||||
it('routes canonical worktree keys using their raw worktree owner', () => {
|
||||
const worktreeId = 'remote-repo::/srv/remote-wt'
|
||||
const owner = buildHostIdByWorktreeId({
|
||||
repos: [],
|
||||
worktreesByRepo: {
|
||||
'remote-repo': [{ id: worktreeId, repoId: 'remote-repo', hostId: 'runtime:env-1' }]
|
||||
}
|
||||
})
|
||||
|
||||
expect(owner(worktreeWorkspaceKey(worktreeId))).toBe('runtime:env-1')
|
||||
})
|
||||
})
|
||||
|
|
@ -10,6 +10,7 @@ import {
|
|||
parseExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../../shared/execution-host'
|
||||
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import { getRepoIdFromWorktreeId } from '../../../shared/worktree-id'
|
||||
import {
|
||||
mergeWorkspaceSessionsFromHosts,
|
||||
|
|
@ -20,7 +21,10 @@ import {
|
|||
|
||||
export type HostPersistenceState = {
|
||||
repos: readonly Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>[]
|
||||
worktreesByRepo: Record<string, readonly Pick<Worktree, 'id' | 'repoId'>[]>
|
||||
projectGroups?: readonly { id: string; executionHostId?: string | null }[]
|
||||
folderWorkspaces?: readonly { id: string; projectGroupId: string }[]
|
||||
worktreesByRepo: Record<string, readonly Pick<Worktree, 'id' | 'repoId' | 'hostId'>[]>
|
||||
restoredRuntimeHostIdByWorkspaceSessionKey?: Record<string, ExecutionHostId>
|
||||
}
|
||||
|
||||
type SessionApi = {
|
||||
|
|
@ -29,6 +33,119 @@ type SessionApi = {
|
|||
setSync: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => void
|
||||
}
|
||||
|
||||
export type WorkspaceSessionHostRead = {
|
||||
session: WorkspaceSessionState
|
||||
runtimeHostIdByWorkspaceSessionKey: Record<string, ExecutionHostId>
|
||||
}
|
||||
|
||||
const WORKSPACE_SESSION_KEYED_FIELDS = [
|
||||
'tabsByWorktree',
|
||||
'openFilesByWorktree',
|
||||
'activeFileIdByWorktree',
|
||||
'activeBrowserTabIdByWorktree',
|
||||
'activeTabTypeByWorktree',
|
||||
'activeTabIdByWorktree',
|
||||
'browserTabsByWorktree',
|
||||
'unifiedTabs',
|
||||
'tabGroups',
|
||||
'tabGroupLayouts',
|
||||
'activeGroupIdByWorktree',
|
||||
'lastVisitedAtByWorktreeId',
|
||||
'defaultTerminalTabsAppliedByWorktreeId'
|
||||
] as const satisfies readonly (keyof WorkspaceSessionState)[]
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function normalizeWorkspaceSessionKeyForOwnerMap(value: string): string {
|
||||
const scope = parseWorkspaceKey(value)
|
||||
return scope?.type === 'worktree' ? scope.worktreeId : value
|
||||
}
|
||||
|
||||
function addWorkspaceSessionKeyForOwnerMap(ids: Set<string>, value: unknown): void {
|
||||
if (typeof value === 'string') {
|
||||
ids.add(normalizeWorkspaceSessionKeyForOwnerMap(value))
|
||||
}
|
||||
}
|
||||
|
||||
function collectWorkspaceSessionKeysFromHostSession(session: WorkspaceSessionState): string[] {
|
||||
const ids = new Set<string>()
|
||||
for (const field of WORKSPACE_SESSION_KEYED_FIELDS) {
|
||||
const value = session[field]
|
||||
if (isPlainRecord(value)) {
|
||||
for (const id of Object.keys(value)) {
|
||||
addWorkspaceSessionKeyForOwnerMap(ids, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const id of session.activeWorktreeIdsOnShutdown ?? []) {
|
||||
addWorkspaceSessionKeyForOwnerMap(ids, id)
|
||||
}
|
||||
for (const pages of Object.values(session.browserPagesByWorkspace ?? {})) {
|
||||
if (!Array.isArray(pages)) {
|
||||
continue
|
||||
}
|
||||
for (const page of pages) {
|
||||
addWorkspaceSessionKeyForOwnerMap(ids, page.worktreeId)
|
||||
}
|
||||
}
|
||||
for (const record of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) {
|
||||
// Why: a hibernated agent can be the only restored session evidence for a
|
||||
// runtime worktree before its remote catalog answers.
|
||||
addWorkspaceSessionKeyForOwnerMap(ids, record.worktreeId)
|
||||
}
|
||||
return [...ids]
|
||||
}
|
||||
|
||||
function buildRuntimeHostIdByWorkspaceSessionKey(
|
||||
slices: HostSessionSlices
|
||||
): Record<string, ExecutionHostId> {
|
||||
const owners: Record<string, ExecutionHostId> = {}
|
||||
for (const [hostId, slice] of nonLocalEntries(slices)) {
|
||||
for (const worktreeId of collectWorkspaceSessionKeysFromHostSession(slice)) {
|
||||
owners[worktreeId] = hostId
|
||||
}
|
||||
}
|
||||
return owners
|
||||
}
|
||||
|
||||
function getRestoredRuntimeHostId(
|
||||
owners: Record<string, ExecutionHostId> | undefined,
|
||||
key: string
|
||||
): ExecutionHostId | null {
|
||||
const hostId = owners?.[key]
|
||||
return hostId && parseExecutionHostId(hostId)?.kind === 'runtime' ? hostId : null
|
||||
}
|
||||
|
||||
function getFolderWorkspaceRuntimeHostId(
|
||||
state: HostPersistenceState,
|
||||
key: string
|
||||
): ExecutionHostId {
|
||||
const scope = parseWorkspaceKey(key)
|
||||
if (scope?.type !== 'folder') {
|
||||
return LOCAL_EXECUTION_HOST_ID
|
||||
}
|
||||
const workspace = state.folderWorkspaces?.find((entry) => entry.id === scope.folderWorkspaceId)
|
||||
const group = workspace
|
||||
? state.projectGroups?.find((entry) => entry.id === workspace.projectGroupId)
|
||||
: null
|
||||
const parsed = parseExecutionHostId(group?.executionHostId)
|
||||
if (parsed) {
|
||||
return parsed.kind === 'runtime' ? parsed.id : LOCAL_EXECUTION_HOST_ID
|
||||
}
|
||||
if (workspace && group) {
|
||||
// Why: once the folder and group catalogs are both known, a missing runtime
|
||||
// owner is authoritative local/SSH persistence, not a startup gap.
|
||||
return LOCAL_EXECUTION_HOST_ID
|
||||
}
|
||||
const restoredHostId = getRestoredRuntimeHostId(
|
||||
state.restoredRuntimeHostIdByWorkspaceSessionKey,
|
||||
key
|
||||
)
|
||||
return restoredHostId ?? LOCAL_EXECUTION_HOST_ID
|
||||
}
|
||||
|
||||
/** Map a worktree to the host partition it persists under.
|
||||
*
|
||||
* Why: only `runtime:*` worktrees are partitioned out. SSH-owned worktrees stay
|
||||
|
|
@ -36,21 +153,43 @@ type SessionApi = {
|
|||
* the unified blob) and separately mirrors them to each target's remote
|
||||
* snapshot — partitioning them too would double-own that data. */
|
||||
export function buildHostIdByWorktreeId(state: HostPersistenceState): HostIdByWorktreeId {
|
||||
const repoById = new Map(state.repos.map((repo) => [repo.id, repo]))
|
||||
const repoHostById = new Map<string, ExecutionHostId | null>()
|
||||
for (const repo of state.repos) {
|
||||
const hostId = getRepoExecutionHostId(repo)
|
||||
const existing = repoHostById.get(repo.id)
|
||||
// Why: repo ids can repeat across hosts; ambiguous repo-only ownership
|
||||
// must not let a runtime placeholder steal local session state.
|
||||
repoHostById.set(repo.id, existing === undefined ? hostId : existing === hostId ? hostId : null)
|
||||
}
|
||||
const repoIdByWorktreeId = new Map<string, string>()
|
||||
const runtimeHostIdByWorktreeId = new Map<string, ExecutionHostId>()
|
||||
for (const worktrees of Object.values(state.worktreesByRepo)) {
|
||||
for (const worktree of worktrees) {
|
||||
repoIdByWorktreeId.set(worktree.id, worktree.repoId)
|
||||
const parsedWorktreeHost = parseExecutionHostId(worktree.hostId)
|
||||
if (parsedWorktreeHost?.kind === 'runtime') {
|
||||
runtimeHostIdByWorktreeId.set(worktree.id, parsedWorktreeHost.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (worktreeId: string): ExecutionHostId => {
|
||||
const repoId = repoIdByWorktreeId.get(worktreeId) ?? getRepoIdFromWorktreeId(worktreeId)
|
||||
const repo = repoId ? repoById.get(repoId) : undefined
|
||||
if (!repo) {
|
||||
const workspaceScope = parseWorkspaceKey(worktreeId)
|
||||
if (workspaceScope?.type === 'folder') {
|
||||
return getFolderWorkspaceRuntimeHostId(state, worktreeId)
|
||||
}
|
||||
const rawWorktreeId =
|
||||
workspaceScope?.type === 'worktree' ? workspaceScope.worktreeId : worktreeId
|
||||
const worktreeHostId = runtimeHostIdByWorktreeId.get(rawWorktreeId)
|
||||
if (worktreeHostId) {
|
||||
return worktreeHostId
|
||||
}
|
||||
const repoId = repoIdByWorktreeId.get(rawWorktreeId) ?? getRepoIdFromWorktreeId(rawWorktreeId)
|
||||
const repoHostId = repoId ? repoHostById.get(repoId) : undefined
|
||||
if (!repoHostId) {
|
||||
return LOCAL_EXECUTION_HOST_ID
|
||||
}
|
||||
const parsed = parseExecutionHostId(getRepoExecutionHostId(repo))
|
||||
const parsed = parseExecutionHostId(repoHostId)
|
||||
return parsed?.kind === 'runtime' ? parsed.id : LOCAL_EXECUTION_HOST_ID
|
||||
}
|
||||
}
|
||||
|
|
@ -112,21 +251,37 @@ export function listKnownRuntimeHostIds(
|
|||
}
|
||||
|
||||
/** Boot-time hydration: fetch the local partition plus one partition per known
|
||||
* runtime host (repos are already loaded before session hydration in App.tsx)
|
||||
* and merge them into the unified session the hydrators expect.
|
||||
* runtime host (from loaded repos and saved runtime ids), then merge them into
|
||||
* the unified session the hydrators expect.
|
||||
*
|
||||
* Fail-soft: a partition whose fetch rejects is skipped — boot proceeds with
|
||||
* the rest. Corrupt partitions never reach here; persistence zod-validates
|
||||
* each one and falls back to defaults on the main side. */
|
||||
export async function fetchWorkspaceSessionFromHosts(
|
||||
api: Pick<SessionApi, 'get'>,
|
||||
repos: readonly Pick<Repo, 'connectionId' | 'executionHostId'>[]
|
||||
repos: readonly Pick<Repo, 'connectionId' | 'executionHostId'>[],
|
||||
additionalRuntimeHostIds: readonly ExecutionHostId[] = []
|
||||
): Promise<WorkspaceSessionState> {
|
||||
return (await fetchWorkspaceSessionWithRuntimeHostOwners(api, repos, additionalRuntimeHostIds))
|
||||
.session
|
||||
}
|
||||
|
||||
export async function fetchWorkspaceSessionWithRuntimeHostOwners(
|
||||
api: Pick<SessionApi, 'get'>,
|
||||
repos: readonly Pick<Repo, 'connectionId' | 'executionHostId'>[],
|
||||
additionalRuntimeHostIds: readonly ExecutionHostId[] = []
|
||||
): Promise<WorkspaceSessionHostRead> {
|
||||
const slices: HostSessionSlices = {
|
||||
[LOCAL_EXECUTION_HOST_ID]: await api.get()
|
||||
}
|
||||
// Why: startup can know saved runtime session hosts before their repo
|
||||
// catalogs hydrate, so include those partitions in the first read.
|
||||
const runtimeHostIds = new Set<ExecutionHostId>([
|
||||
...listKnownRuntimeHostIds(repos),
|
||||
...additionalRuntimeHostIds
|
||||
])
|
||||
await Promise.all(
|
||||
listKnownRuntimeHostIds(repos).map(async (hostId) => {
|
||||
[...runtimeHostIds].map(async (hostId) => {
|
||||
try {
|
||||
slices[hostId] = await api.get(hostId)
|
||||
} catch (err) {
|
||||
|
|
@ -134,5 +289,8 @@ export async function fetchWorkspaceSessionFromHosts(
|
|||
}
|
||||
})
|
||||
)
|
||||
return mergeWorkspaceSessionsFromHosts(slices)
|
||||
return {
|
||||
session: mergeWorkspaceSessionsFromHosts(slices),
|
||||
runtimeHostIdByWorkspaceSessionKey: buildRuntimeHostIdByWorkspaceSessionKey(slices)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
import type { WorkspaceKey, WorkspaceSessionState } from '../../../shared/types'
|
||||
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
|
||||
export type WorkspaceSessionHydrationOptions = {
|
||||
additionalValidWorkspaceKeys?: readonly WorkspaceKey[]
|
||||
}
|
||||
|
||||
const WORKSPACE_KEYED_SESSION_FIELDS = [
|
||||
'tabsByWorktree',
|
||||
'openFilesByWorktree',
|
||||
'activeFileIdByWorktree',
|
||||
'activeBrowserTabIdByWorktree',
|
||||
'activeTabTypeByWorktree',
|
||||
'activeTabIdByWorktree',
|
||||
'browserTabsByWorktree',
|
||||
'unifiedTabs',
|
||||
'tabGroups',
|
||||
'tabGroupLayouts',
|
||||
'activeGroupIdByWorktree',
|
||||
'lastVisitedAtByWorktreeId',
|
||||
'defaultTerminalTabsAppliedByWorktreeId'
|
||||
] as const satisfies readonly (keyof WorkspaceSessionState)[]
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function addFolderWorkspaceKey(keys: Set<WorkspaceKey>, value: unknown): void {
|
||||
if (typeof value !== 'string') {
|
||||
return
|
||||
}
|
||||
const scope = parseWorkspaceKey(value)
|
||||
if (scope?.type === 'folder') {
|
||||
keys.add(value as WorkspaceKey)
|
||||
}
|
||||
}
|
||||
|
||||
export function collectFolderWorkspaceKeysFromSession(
|
||||
session: WorkspaceSessionState
|
||||
): WorkspaceKey[] {
|
||||
const keys = new Set<WorkspaceKey>()
|
||||
|
||||
addFolderWorkspaceKey(keys, session.activeWorkspaceKey)
|
||||
addFolderWorkspaceKey(keys, session.activeWorktreeId)
|
||||
|
||||
for (const field of WORKSPACE_KEYED_SESSION_FIELDS) {
|
||||
const value = session[field]
|
||||
if (!isPlainRecord(value)) {
|
||||
continue
|
||||
}
|
||||
for (const key of Object.keys(value)) {
|
||||
addFolderWorkspaceKey(keys, key)
|
||||
}
|
||||
}
|
||||
|
||||
for (const worktreeId of session.activeWorktreeIdsOnShutdown ?? []) {
|
||||
addFolderWorkspaceKey(keys, worktreeId)
|
||||
}
|
||||
for (const pages of Object.values(session.browserPagesByWorkspace ?? {})) {
|
||||
if (!Array.isArray(pages)) {
|
||||
continue
|
||||
}
|
||||
for (const page of pages) {
|
||||
addFolderWorkspaceKey(keys, page.worktreeId)
|
||||
}
|
||||
}
|
||||
for (const record of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) {
|
||||
addFolderWorkspaceKey(keys, record.worktreeId)
|
||||
}
|
||||
|
||||
return [...keys]
|
||||
}
|
||||
|
||||
export function addAdditionalValidWorkspaceKeys(
|
||||
validWorkspaceIds: Set<string>,
|
||||
options?: WorkspaceSessionHydrationOptions
|
||||
): void {
|
||||
for (const key of options?.additionalValidWorkspaceKeys ?? []) {
|
||||
validWorkspaceIds.add(key)
|
||||
}
|
||||
}
|
||||
|
|
@ -62,11 +62,46 @@ describe('getSettingsForWorktreeRuntimeOwner', () => {
|
|||
expect(getExecutionHostIdForWorktree(state, 'folder:runtime-folder')).toBe('runtime:folder-env')
|
||||
})
|
||||
|
||||
it('routes restored runtime folder workspaces before their catalog loads', () => {
|
||||
const restoredFolderState: WorktreeRuntimeOwnerState = {
|
||||
settings: { activeRuntimeEnvironmentId: 'focused-env' },
|
||||
folderWorkspaces: [],
|
||||
projectGroups: [],
|
||||
restoredRuntimeHostIdByWorkspaceSessionKey: {
|
||||
'folder:restored-folder': 'runtime:restored-env'
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
getSettingsForWorktreeRuntimeOwner(restoredFolderState, 'folder:restored-folder')
|
||||
).toEqual({
|
||||
activeRuntimeEnvironmentId: 'restored-env'
|
||||
})
|
||||
expect(getRuntimeEnvironmentIdForWorktree(restoredFolderState, 'folder:restored-folder')).toBe(
|
||||
'restored-env'
|
||||
)
|
||||
expect(
|
||||
getExplicitRuntimeEnvironmentIdForWorktree(restoredFolderState, 'folder:restored-folder')
|
||||
).toBe('restored-env')
|
||||
expect(getExecutionHostIdForWorktree(restoredFolderState, 'folder:restored-folder')).toBe(
|
||||
'runtime:restored-env'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps explicit-local folder workspaces local even while a runtime is focused', () => {
|
||||
expect(getSettingsForWorktreeRuntimeOwner(state, 'folder:local-folder')).toEqual({
|
||||
activeRuntimeEnvironmentId: null
|
||||
})
|
||||
expect(getExecutionHostIdForWorktree(state, 'folder:local-folder')).toBe('local')
|
||||
|
||||
const restoredOwnerState: WorktreeRuntimeOwnerState = {
|
||||
...state,
|
||||
restoredRuntimeHostIdByWorkspaceSessionKey: {
|
||||
'folder:local-folder': 'runtime:stale-env'
|
||||
}
|
||||
}
|
||||
expect(getRuntimeEnvironmentIdForWorktree(restoredOwnerState, 'folder:local-folder')).toBeNull()
|
||||
expect(getExecutionHostIdForWorktree(restoredOwnerState, 'folder:local-folder')).toBe('local')
|
||||
})
|
||||
|
||||
it('keeps folder workspaces with their own SSH target off the focused runtime', () => {
|
||||
|
|
@ -188,6 +223,17 @@ describe('getRuntimeSessionMirrorEnvironmentIds', () => {
|
|||
])
|
||||
})
|
||||
|
||||
it('includes restored runtime folder owners before their catalog loads', () => {
|
||||
expect(
|
||||
getRuntimeSessionMirrorEnvironmentIds({
|
||||
settings: { activeRuntimeEnvironmentId: 'focused-env' },
|
||||
restoredRuntimeHostIdByWorkspaceSessionKey: {
|
||||
'folder:restored-folder': 'runtime:restored-env'
|
||||
}
|
||||
})
|
||||
).toEqual(['focused-env', 'restored-env'])
|
||||
})
|
||||
|
||||
it('does not include local or SSH owners', () => {
|
||||
const localOnlyState: WorktreeRuntimeOwnerState = {
|
||||
settings: { activeRuntimeEnvironmentId: null },
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import {
|
|||
parseExecutionHostId,
|
||||
toSshExecutionHostId
|
||||
} from '../../../shared/execution-host'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { ExecutionHostId, ParsedExecutionHost } from '../../../shared/execution-host'
|
||||
import type {
|
||||
FolderWorkspace,
|
||||
GlobalSettings,
|
||||
|
|
@ -11,16 +11,19 @@ import type {
|
|||
Repo,
|
||||
Worktree
|
||||
} from '../../../shared/types'
|
||||
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import { folderWorkspaceKey, parseWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
|
||||
import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers'
|
||||
|
||||
type RuntimeExecutionHost = Extract<ParsedExecutionHost, { kind: 'runtime' }>
|
||||
|
||||
export type WorktreeRuntimeOwnerState = {
|
||||
repos?: readonly Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>[]
|
||||
settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null
|
||||
worktreesByRepo?: Record<string, readonly Pick<Worktree, 'id' | 'repoId' | 'hostId'>[]>
|
||||
folderWorkspaces?: readonly Pick<FolderWorkspace, 'id' | 'projectGroupId' | 'connectionId'>[]
|
||||
projectGroups?: readonly Pick<ProjectGroup, 'id' | 'connectionId' | 'executionHostId'>[]
|
||||
restoredRuntimeHostIdByWorkspaceSessionKey?: Record<string, ExecutionHostId>
|
||||
}
|
||||
|
||||
function findWorktreeRecord(
|
||||
|
|
@ -72,9 +75,26 @@ function getRuntimeEnvironmentIdForFolderWorkspace(
|
|||
) {
|
||||
return null
|
||||
}
|
||||
const restoredRuntimeHost = getRestoredRuntimeHostForFolderWorkspace(state, folderWorkspaceId)
|
||||
if (restoredRuntimeHost) {
|
||||
return restoredRuntimeHost.environmentId
|
||||
}
|
||||
return state.settings?.activeRuntimeEnvironmentId?.trim() || null
|
||||
}
|
||||
|
||||
function getRestoredRuntimeHostForFolderWorkspace(
|
||||
state: WorktreeRuntimeOwnerState,
|
||||
folderWorkspaceId: string
|
||||
): RuntimeExecutionHost | null {
|
||||
// Why: runtime folder catalogs load after session hydration; the saved
|
||||
// per-host session partition is the only owner evidence during that gap.
|
||||
const workspaceKey = folderWorkspaceKey(folderWorkspaceId)
|
||||
const parsed = parseExecutionHostId(
|
||||
state.restoredRuntimeHostIdByWorkspaceSessionKey?.[workspaceKey]
|
||||
)
|
||||
return parsed?.kind === 'runtime' ? parsed : null
|
||||
}
|
||||
|
||||
function getExplicitRuntimeEnvironmentIdFromHost(
|
||||
executionHostId: string | null | undefined
|
||||
): string | null {
|
||||
|
|
@ -101,9 +121,16 @@ function getExplicitRuntimeEnvironmentIdForFolderWorkspace(
|
|||
state: WorktreeRuntimeOwnerState,
|
||||
folderWorkspaceId: string
|
||||
): string | null {
|
||||
return getExplicitRuntimeEnvironmentIdFromHost(
|
||||
findFolderProjectGroup(state, folderWorkspaceId)?.executionHostId
|
||||
)
|
||||
const folderWorkspace = findFolderWorkspace(state, folderWorkspaceId)
|
||||
const projectGroup = findFolderProjectGroup(state, folderWorkspaceId)
|
||||
const parsed = parseExecutionHostId(projectGroup?.executionHostId)
|
||||
if (parsed) {
|
||||
return parsed.kind === 'runtime' ? parsed.environmentId : null
|
||||
}
|
||||
if (folderWorkspace?.connectionId?.trim() || projectGroup?.connectionId?.trim()) {
|
||||
return null
|
||||
}
|
||||
return getRestoredRuntimeHostForFolderWorkspace(state, folderWorkspaceId)?.environmentId ?? null
|
||||
}
|
||||
|
||||
function getExecutionHostIdForFolderWorkspace(
|
||||
|
|
@ -120,6 +147,10 @@ function getExecutionHostIdForFolderWorkspace(
|
|||
if (connectionId) {
|
||||
return toSshExecutionHostId(connectionId)
|
||||
}
|
||||
const restoredRuntimeHost = getRestoredRuntimeHostForFolderWorkspace(state, folderWorkspaceId)
|
||||
if (restoredRuntimeHost) {
|
||||
return restoredRuntimeHost.id
|
||||
}
|
||||
const environmentId = state.settings?.activeRuntimeEnvironmentId?.trim()
|
||||
return environmentId ? `runtime:${encodeURIComponent(environmentId)}` : 'local'
|
||||
}
|
||||
|
|
@ -209,6 +240,12 @@ export function getRuntimeSessionMirrorEnvironmentIds(state: WorktreeRuntimeOwne
|
|||
ids.add(environmentId)
|
||||
}
|
||||
}
|
||||
for (const hostId of Object.values(state.restoredRuntimeHostIdByWorkspaceSessionKey ?? {})) {
|
||||
const parsed = parseExecutionHostId(hostId)
|
||||
if (parsed?.kind === 'runtime') {
|
||||
ids.add(parsed.environmentId)
|
||||
}
|
||||
}
|
||||
return [...ids].sort()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -136,6 +136,133 @@ describe('runtime RPC client routing', () => {
|
|||
])
|
||||
})
|
||||
|
||||
it('reuses recent remote compatibility failures during startup catalog bursts', async () => {
|
||||
runtimeEnvironmentCall.mockResolvedValue({
|
||||
id: 'status',
|
||||
ok: false,
|
||||
error: { code: 'runtime_unavailable', message: 'offline' },
|
||||
_meta: { runtimeId: 'remote-runtime' }
|
||||
})
|
||||
const target = { kind: 'environment', environmentId: 'env-offline' } as const
|
||||
|
||||
await expect(
|
||||
callRuntimeRpc(target, 'repo.list', undefined, { reuseRecentCompatibilityFailure: true })
|
||||
).rejects.toThrow('offline')
|
||||
await expect(
|
||||
callRuntimeRpc(target, 'projectGroup.list', undefined, {
|
||||
reuseRecentCompatibilityFailure: true
|
||||
})
|
||||
).rejects.toThrow('offline')
|
||||
await expect(
|
||||
callRuntimeRpc(target, 'folderWorkspace.list', undefined, {
|
||||
reuseRecentCompatibilityFailure: true
|
||||
})
|
||||
).rejects.toThrow('offline')
|
||||
|
||||
expect(runtimeEnvironmentCall.mock.calls.map((call) => call[0].method)).toEqual(['status.get'])
|
||||
})
|
||||
|
||||
it('expires startup compatibility failures at the TTL boundary', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date(0))
|
||||
try {
|
||||
let statusCalls = 0
|
||||
runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => {
|
||||
if (method === 'status.get') {
|
||||
statusCalls += 1
|
||||
if (statusCalls === 1) {
|
||||
return Promise.resolve({
|
||||
id: 'status',
|
||||
ok: false,
|
||||
error: { code: 'runtime_unavailable', message: 'offline' },
|
||||
_meta: { runtimeId: 'remote-runtime' }
|
||||
})
|
||||
}
|
||||
return Promise.resolve({
|
||||
id: 'status',
|
||||
ok: true,
|
||||
result: {
|
||||
runtimeId: 'remote-runtime',
|
||||
graphStatus: 'ready',
|
||||
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
|
||||
minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION
|
||||
},
|
||||
_meta: { runtimeId: 'remote-runtime' }
|
||||
})
|
||||
}
|
||||
return Promise.resolve({
|
||||
id: method,
|
||||
ok: true,
|
||||
result: { ok: true },
|
||||
_meta: { runtimeId: 'remote-runtime' }
|
||||
})
|
||||
})
|
||||
const target = { kind: 'environment', environmentId: 'env-ttl' } as const
|
||||
|
||||
await expect(
|
||||
callRuntimeRpc(target, 'repo.list', undefined, { reuseRecentCompatibilityFailure: true })
|
||||
).rejects.toThrow('offline')
|
||||
vi.setSystemTime(new Date(60_000))
|
||||
await expect(
|
||||
callRuntimeRpc(target, 'repo.list', undefined, { reuseRecentCompatibilityFailure: true })
|
||||
).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(runtimeEnvironmentCall.mock.calls.map((call) => call[0].method)).toEqual([
|
||||
'status.get',
|
||||
'status.get',
|
||||
'repo.list'
|
||||
])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('retries normal remote calls after a catalog-burst compatibility failure', async () => {
|
||||
let statusCalls = 0
|
||||
runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => {
|
||||
if (method === 'status.get') {
|
||||
statusCalls += 1
|
||||
if (statusCalls === 1) {
|
||||
return Promise.resolve({
|
||||
id: 'status',
|
||||
ok: false,
|
||||
error: { code: 'runtime_unavailable', message: 'offline' },
|
||||
_meta: { runtimeId: 'remote-runtime' }
|
||||
})
|
||||
}
|
||||
return Promise.resolve({
|
||||
id: 'status',
|
||||
ok: true,
|
||||
result: {
|
||||
runtimeId: 'remote-runtime',
|
||||
graphStatus: 'ready',
|
||||
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
|
||||
minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION
|
||||
},
|
||||
_meta: { runtimeId: 'remote-runtime' }
|
||||
})
|
||||
}
|
||||
return Promise.resolve({
|
||||
id: method,
|
||||
ok: true,
|
||||
result: { ok: true },
|
||||
_meta: { runtimeId: 'remote-runtime' }
|
||||
})
|
||||
})
|
||||
const target = { kind: 'environment', environmentId: 'env-recovers' } as const
|
||||
|
||||
await expect(
|
||||
callRuntimeRpc(target, 'repo.list', undefined, { reuseRecentCompatibilityFailure: true })
|
||||
).rejects.toThrow('offline')
|
||||
await expect(callRuntimeRpc(target, 'git.status')).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(runtimeEnvironmentCall.mock.calls.map((call) => call[0].method)).toEqual([
|
||||
'status.get',
|
||||
'status.get',
|
||||
'git.status'
|
||||
])
|
||||
})
|
||||
|
||||
it('checks advertised runtime capabilities after protocol compatibility', async () => {
|
||||
runtimeEnvironmentCall.mockResolvedValue({
|
||||
id: 'status',
|
||||
|
|
|
|||
|
|
@ -8,7 +8,15 @@ import { assertRuntimeStatusCompatible } from './runtime-protocol-compat'
|
|||
export type RuntimeClientTarget = { kind: 'local' } | { kind: 'environment'; environmentId: string }
|
||||
|
||||
const RUNTIME_COMPATIBILITY_CACHE_MAX = 32
|
||||
const compatibleRuntimeEnvironments = new Map<string, Promise<void>>()
|
||||
const RECENT_RUNTIME_COMPATIBILITY_FAILURE_TTL_MS = 60_000
|
||||
|
||||
type RuntimeCompatibilityCacheEntry = {
|
||||
check: Promise<void>
|
||||
failedAt: number | null
|
||||
reuseFailure: boolean
|
||||
}
|
||||
|
||||
const runtimeCompatibilityChecks = new Map<string, RuntimeCompatibilityCacheEntry>()
|
||||
|
||||
export class RuntimeRpcCallError extends Error {
|
||||
readonly code: string
|
||||
|
|
@ -54,10 +62,14 @@ export async function callRuntimeRpc<TResult>(
|
|||
target: RuntimeClientTarget,
|
||||
method: string,
|
||||
params?: unknown,
|
||||
options: { timeoutMs?: number; suppressFeatureInteraction?: boolean } = {}
|
||||
options: {
|
||||
timeoutMs?: number
|
||||
suppressFeatureInteraction?: boolean
|
||||
reuseRecentCompatibilityFailure?: boolean
|
||||
} = {}
|
||||
): Promise<TResult> {
|
||||
if (target.kind === 'environment' && method !== 'status.get') {
|
||||
await ensureRuntimeEnvironmentCompatible(target.environmentId, options.timeoutMs)
|
||||
await ensureRuntimeEnvironmentCompatible(target.environmentId, options)
|
||||
}
|
||||
const nextParams = addFeatureInteractionSource(params, options)
|
||||
const response =
|
||||
|
|
@ -84,61 +96,93 @@ function addFeatureInteractionSource(
|
|||
|
||||
async function ensureRuntimeEnvironmentCompatible(
|
||||
environmentId: string,
|
||||
timeoutMs?: number
|
||||
options: { timeoutMs?: number; reuseRecentCompatibilityFailure?: boolean } = {}
|
||||
): Promise<void> {
|
||||
const cached = compatibleRuntimeEnvironments.get(environmentId)
|
||||
const cached = getCachedRuntimeCompatibilityCheck(environmentId, options)
|
||||
if (cached) {
|
||||
compatibleRuntimeEnvironments.delete(environmentId)
|
||||
compatibleRuntimeEnvironments.set(environmentId, cached)
|
||||
await cached
|
||||
await cached.check
|
||||
return
|
||||
}
|
||||
const entry: RuntimeCompatibilityCacheEntry = {
|
||||
check: Promise.resolve(),
|
||||
failedAt: null,
|
||||
reuseFailure: options.reuseRecentCompatibilityFailure === true
|
||||
}
|
||||
const check = (async () => {
|
||||
const response = await window.api.runtimeEnvironments.call({
|
||||
selector: environmentId,
|
||||
method: 'status.get',
|
||||
timeoutMs
|
||||
timeoutMs: options.timeoutMs
|
||||
})
|
||||
const status = unwrapRuntimeRpcResult<RuntimeStatus>(
|
||||
response as RuntimeRpcResponse<RuntimeStatus>
|
||||
)
|
||||
assertRuntimeStatusCompatible(status)
|
||||
})()
|
||||
rememberRuntimeEnvironmentCompatibility(environmentId, check)
|
||||
entry.check = check
|
||||
rememberRuntimeEnvironmentCompatibility(environmentId, entry)
|
||||
try {
|
||||
await check
|
||||
} catch (error) {
|
||||
if (compatibleRuntimeEnvironments.get(environmentId) === check) {
|
||||
compatibleRuntimeEnvironments.delete(environmentId)
|
||||
if (runtimeCompatibilityChecks.get(environmentId) === entry) {
|
||||
// Why: startup asks each remote for repos, groups, then folders; an
|
||||
// offline runtime should pay one timeout during that burst, not three.
|
||||
entry.failedAt = Date.now()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function getCachedRuntimeCompatibilityCheck(
|
||||
environmentId: string,
|
||||
options: { reuseRecentCompatibilityFailure?: boolean }
|
||||
): RuntimeCompatibilityCacheEntry | null {
|
||||
const cached = runtimeCompatibilityChecks.get(environmentId)
|
||||
if (!cached) {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
cached.failedAt !== null &&
|
||||
Date.now() - cached.failedAt >= RECENT_RUNTIME_COMPATIBILITY_FAILURE_TTL_MS
|
||||
) {
|
||||
runtimeCompatibilityChecks.delete(environmentId)
|
||||
return null
|
||||
}
|
||||
if (
|
||||
cached.failedAt !== null &&
|
||||
(!cached.reuseFailure || options.reuseRecentCompatibilityFailure !== true)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
runtimeCompatibilityChecks.delete(environmentId)
|
||||
runtimeCompatibilityChecks.set(environmentId, cached)
|
||||
return cached
|
||||
}
|
||||
|
||||
function rememberRuntimeEnvironmentCompatibility(
|
||||
environmentId: string,
|
||||
check: Promise<void>
|
||||
entry: RuntimeCompatibilityCacheEntry
|
||||
): void {
|
||||
// Why: saved/removed remote runtimes can churn through unique ids in long
|
||||
// renderer sessions; successful compatibility promises should not grow forever.
|
||||
compatibleRuntimeEnvironments.delete(environmentId)
|
||||
compatibleRuntimeEnvironments.set(environmentId, check)
|
||||
while (compatibleRuntimeEnvironments.size > RUNTIME_COMPATIBILITY_CACHE_MAX) {
|
||||
const oldest = compatibleRuntimeEnvironments.keys().next().value
|
||||
// renderer sessions; compatibility cache entries should not grow forever.
|
||||
runtimeCompatibilityChecks.delete(environmentId)
|
||||
runtimeCompatibilityChecks.set(environmentId, entry)
|
||||
while (runtimeCompatibilityChecks.size > RUNTIME_COMPATIBILITY_CACHE_MAX) {
|
||||
const oldest = runtimeCompatibilityChecks.keys().next().value
|
||||
if (oldest === undefined) {
|
||||
break
|
||||
}
|
||||
compatibleRuntimeEnvironments.delete(oldest)
|
||||
runtimeCompatibilityChecks.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearRuntimeCompatibilityCache(environmentId?: string | null): void {
|
||||
const trimmed = environmentId?.trim()
|
||||
if (trimmed) {
|
||||
compatibleRuntimeEnvironments.delete(trimmed)
|
||||
runtimeCompatibilityChecks.delete(trimmed)
|
||||
return
|
||||
}
|
||||
compatibleRuntimeEnvironments.clear()
|
||||
runtimeCompatibilityChecks.clear()
|
||||
}
|
||||
|
||||
export function markRuntimeEnvironmentCompatible(environmentId: string): void {
|
||||
|
|
@ -146,7 +190,11 @@ export function markRuntimeEnvironmentCompatible(environmentId: string): void {
|
|||
if (!trimmed) {
|
||||
return
|
||||
}
|
||||
rememberRuntimeEnvironmentCompatibility(trimmed, Promise.resolve())
|
||||
rememberRuntimeEnvironmentCompatibility(trimmed, {
|
||||
check: Promise.resolve(),
|
||||
failedAt: null,
|
||||
reuseFailure: false
|
||||
})
|
||||
}
|
||||
|
||||
export async function getRuntimeEnvironmentStatus(
|
||||
|
|
|
|||
|
|
@ -49,6 +49,10 @@ import {
|
|||
getExecutionHostIdForWorktree,
|
||||
getRuntimeEnvironmentIdForWorktree
|
||||
} from '@/lib/worktree-runtime-owner'
|
||||
import {
|
||||
addAdditionalValidWorkspaceKeys,
|
||||
type WorkspaceSessionHydrationOptions
|
||||
} from '@/lib/workspace-session-hydration-keys'
|
||||
|
||||
type CreateBrowserTabOptions = {
|
||||
activate?: boolean
|
||||
|
|
@ -168,7 +172,10 @@ export type BrowserSlice = {
|
|||
addBrowserPageAnnotation: (annotation: BrowserPageAnnotation) => void
|
||||
deleteBrowserPageAnnotation: (pageId: string, annotationId: string) => void
|
||||
clearBrowserPageAnnotations: (pageId: string) => void
|
||||
hydrateBrowserSession: (session: WorkspaceSessionState) => void
|
||||
hydrateBrowserSession: (
|
||||
session: WorkspaceSessionState,
|
||||
options?: WorkspaceSessionHydrationOptions
|
||||
) => void
|
||||
switchBrowserTabProfile: (
|
||||
workspaceId: string,
|
||||
profileId: string | null,
|
||||
|
|
@ -1504,7 +1511,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
|
|||
return { browserAnnotationsByPageId: nextByPageId }
|
||||
}),
|
||||
|
||||
hydrateBrowserSession: (session) => {
|
||||
hydrateBrowserSession: (session, options) => {
|
||||
const persistedTabsByWorktree = session.browserTabsByWorktree ?? {}
|
||||
const currentState = get()
|
||||
const validWorktreeIdsForCleanup = new Set(
|
||||
|
|
@ -1516,6 +1523,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
|
|||
for (const workspace of currentState.folderWorkspaces) {
|
||||
validWorktreeIdsForCleanup.add(folderWorkspaceKey(workspace.id))
|
||||
}
|
||||
addAdditionalValidWorkspaceKeys(validWorktreeIdsForCleanup, options)
|
||||
|
||||
// Why: mirror closeBrowserTab's contract — reducers are pure, imperative
|
||||
// side effects bracket them. Compute dropped workspaces first, destroy
|
||||
|
|
@ -1548,6 +1556,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
|
|||
for (const workspace of s.folderWorkspaces) {
|
||||
validWorktreeIds.add(folderWorkspaceKey(workspace.id))
|
||||
}
|
||||
addAdditionalValidWorkspaceKeys(validWorktreeIds, options)
|
||||
|
||||
const browserTabsByWorktree: Record<string, BrowserWorkspace[]> = {}
|
||||
const browserPagesByWorkspace: Record<string, BrowserPage[]> = {}
|
||||
|
|
|
|||
|
|
@ -62,6 +62,10 @@ import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
|
|||
import { notifyHostOfMirroredEditorClose } from '@/runtime/close-mirrored-editor-tab'
|
||||
import { findWorktreeById, getRepoIdFromWorktreeId } from './worktree-helpers'
|
||||
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import {
|
||||
addAdditionalValidWorkspaceKeys,
|
||||
type WorkspaceSessionHydrationOptions
|
||||
} from '@/lib/workspace-session-hydration-keys'
|
||||
import { createUntitledMarkdownFileWithTemplateSelection } from '@/lib/create-untitled-markdown'
|
||||
import { extractIpcErrorMessage } from '@/lib/ipc-error'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
|
@ -711,7 +715,10 @@ export type EditorSlice = {
|
|||
setPendingEditorReveal: (reveal: PendingEditorReveal | null) => void
|
||||
|
||||
// Session hydration — restore editor files from persisted workspace session
|
||||
hydrateEditorSession: (session: WorkspaceSessionState) => void
|
||||
hydrateEditorSession: (
|
||||
session: WorkspaceSessionState,
|
||||
options?: WorkspaceSessionHydrationOptions
|
||||
) => void
|
||||
}
|
||||
|
||||
function openWorkspaceEditorItem(
|
||||
|
|
@ -4222,7 +4229,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
// Why: only edit-mode files are restored — diffs and conflict views depend on
|
||||
// transient git state that may have changed between sessions. Restoring them
|
||||
// would show stale data or fail to load entirely.
|
||||
hydrateEditorSession: (session) => {
|
||||
hydrateEditorSession: (session, options) => {
|
||||
set((s) => {
|
||||
const openFilesByWorktree = session.openFilesByWorktree ?? {}
|
||||
const persistedActiveFileIdByWorktree = session.activeFileIdByWorktree ?? {}
|
||||
|
|
@ -4241,6 +4248,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
for (const workspace of s.folderWorkspaces) {
|
||||
validWorktreeIds.add(folderWorkspaceKey(workspace.id))
|
||||
}
|
||||
addAdditionalValidWorkspaceKeys(validWorktreeIds, options)
|
||||
|
||||
const openFiles: OpenFile[] = []
|
||||
const editorDrafts: Record<string, string> = {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,265 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createTestStore } from './store-test-helpers'
|
||||
import type { FolderWorkspace, ProjectGroup, Repo } from '../../../../shared/types'
|
||||
import {
|
||||
createCompatibleRuntimeStatusResponseIfNeeded,
|
||||
type RuntimeEnvironmentCallRequest
|
||||
} from '../../runtime/runtime-compatibility-test-fixture'
|
||||
import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client'
|
||||
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
|
||||
const localRepo: Repo = {
|
||||
id: 'local-repo',
|
||||
path: '/local',
|
||||
displayName: 'Local',
|
||||
badgeColor: '#000',
|
||||
addedAt: 1
|
||||
}
|
||||
|
||||
const remoteRepo: Repo = {
|
||||
id: 'remote-repo',
|
||||
path: '/srv/repo',
|
||||
displayName: 'Remote',
|
||||
badgeColor: '#000',
|
||||
addedAt: 1
|
||||
}
|
||||
|
||||
const localProjectGroup: ProjectGroup = {
|
||||
id: 'local-group',
|
||||
name: 'Local group',
|
||||
parentPath: '/local',
|
||||
parentGroupId: null,
|
||||
createdFrom: 'manual',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
|
||||
const remoteProjectGroup: ProjectGroup = {
|
||||
id: 'remote-group',
|
||||
name: 'Remote group',
|
||||
parentPath: '/srv',
|
||||
parentGroupId: null,
|
||||
createdFrom: 'manual',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
|
||||
const localFolderWorkspace: FolderWorkspace = {
|
||||
id: 'local-folder',
|
||||
projectGroupId: 'local-group',
|
||||
name: 'Local folder',
|
||||
folderPath: '/local',
|
||||
linkedTask: null,
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 1,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
|
||||
const remoteFolderWorkspace: FolderWorkspace = {
|
||||
id: 'remote-folder',
|
||||
projectGroupId: 'remote-group',
|
||||
name: 'Remote folder',
|
||||
folderPath: '/srv',
|
||||
linkedTask: null,
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 1,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
|
||||
const reposList = vi.fn()
|
||||
const projectsList = vi.fn()
|
||||
const listHostSetups = vi.fn()
|
||||
const projectGroupsList = vi.fn()
|
||||
const folderWorkspacesList = vi.fn()
|
||||
const runtimeEnvironmentsList = vi.fn()
|
||||
const runtimeEnvironmentCall = vi.fn()
|
||||
const runtimeEnvironmentTransportCall = vi.fn()
|
||||
const dispatchEventMock = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
clearRuntimeCompatibilityCacheForTests()
|
||||
reposList.mockReset()
|
||||
projectsList.mockReset()
|
||||
listHostSetups.mockReset()
|
||||
projectGroupsList.mockReset()
|
||||
folderWorkspacesList.mockReset()
|
||||
runtimeEnvironmentsList.mockReset()
|
||||
runtimeEnvironmentCall.mockReset()
|
||||
runtimeEnvironmentTransportCall.mockReset()
|
||||
dispatchEventMock.mockReset()
|
||||
|
||||
reposList.mockResolvedValue([localRepo])
|
||||
projectsList.mockResolvedValue([])
|
||||
listHostSetups.mockResolvedValue([])
|
||||
projectGroupsList.mockResolvedValue([localProjectGroup])
|
||||
folderWorkspacesList.mockResolvedValue([localFolderWorkspace])
|
||||
runtimeEnvironmentsList.mockResolvedValue([{ id: 'env-1', name: 'lobster' }])
|
||||
runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
|
||||
if (args.method === 'repo.list') {
|
||||
return {
|
||||
id: 'rpc-repo-list',
|
||||
ok: true,
|
||||
result: { repos: [remoteRepo] },
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
}
|
||||
}
|
||||
if (args.method === 'projectGroup.list') {
|
||||
return {
|
||||
id: 'rpc-project-group-list',
|
||||
ok: true,
|
||||
result: { groups: [remoteProjectGroup] },
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
}
|
||||
}
|
||||
if (args.method === 'folderWorkspace.list') {
|
||||
return {
|
||||
id: 'rpc-folder-workspace-list',
|
||||
ok: true,
|
||||
result: { folderWorkspaces: [remoteFolderWorkspace] },
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: 'rpc-other',
|
||||
ok: true,
|
||||
result: { projects: [], setups: [] },
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
}
|
||||
})
|
||||
runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
|
||||
return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args)
|
||||
})
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
repos: { list: reposList },
|
||||
projects: { list: projectsList, listHostSetups },
|
||||
projectGroups: { list: projectGroupsList },
|
||||
folderWorkspaces: { list: folderWorkspacesList },
|
||||
runtimeEnvironments: {
|
||||
call: runtimeEnvironmentTransportCall,
|
||||
list: runtimeEnvironmentsList
|
||||
}
|
||||
},
|
||||
dispatchEvent: dispatchEventMock
|
||||
})
|
||||
})
|
||||
|
||||
describe('all-host folder workspace startup catalogs', () => {
|
||||
it('loads project groups and folder workspaces for every host', async () => {
|
||||
const store = createTestStore()
|
||||
store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never })
|
||||
const restoredFolderKey = folderWorkspaceKey('remote-folder')
|
||||
store.setState({
|
||||
restoredRuntimeHostIdByWorkspaceSessionKey: {
|
||||
[restoredFolderKey]: 'runtime:env-1',
|
||||
'remote-repo::/srv/repo': 'runtime:env-1'
|
||||
}
|
||||
})
|
||||
|
||||
await store.getState().fetchProjectGroupsForAllHosts()
|
||||
await store.getState().fetchFolderWorkspacesForAllHosts()
|
||||
|
||||
expect(store.getState().projectGroups).toEqual([
|
||||
{ ...localProjectGroup, executionHostId: 'local' },
|
||||
{ ...remoteProjectGroup, executionHostId: 'runtime:env-1' }
|
||||
])
|
||||
expect(store.getState().folderWorkspaces.map((workspace) => workspace.id)).toEqual([
|
||||
'local-folder',
|
||||
'remote-folder'
|
||||
])
|
||||
expect(store.getState().restoredRuntimeHostIdByWorkspaceSessionKey).toEqual({
|
||||
'remote-repo::/srv/repo': 'runtime:env-1'
|
||||
})
|
||||
|
||||
const missingGroupStore = createTestStore()
|
||||
missingGroupStore.setState({
|
||||
settings: { activeRuntimeEnvironmentId: 'env-1' } as never,
|
||||
projectGroups: [localProjectGroup],
|
||||
restoredRuntimeHostIdByWorkspaceSessionKey: { [restoredFolderKey]: 'runtime:env-1' }
|
||||
})
|
||||
await missingGroupStore.getState().fetchFolderWorkspacesForAllHosts()
|
||||
|
||||
expect(missingGroupStore.getState().restoredRuntimeHostIdByWorkspaceSessionKey).toEqual({
|
||||
[restoredFolderKey]: 'runtime:env-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps local project groups and folder workspaces when a runtime is unreachable', async () => {
|
||||
runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
|
||||
if (args.method === 'projectGroup.list' || args.method === 'folderWorkspace.list') {
|
||||
throw new Error('runtime_unreachable')
|
||||
}
|
||||
return {
|
||||
id: 'rpc-other',
|
||||
ok: true,
|
||||
result: { repos: [], projects: [], setups: [] },
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
}
|
||||
})
|
||||
const store = createTestStore()
|
||||
store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never })
|
||||
|
||||
await store.getState().fetchProjectGroupsForAllHosts()
|
||||
await store.getState().fetchFolderWorkspacesForAllHosts()
|
||||
|
||||
expect(store.getState().projectGroups).toEqual([
|
||||
{ ...localProjectGroup, executionHostId: 'local' }
|
||||
])
|
||||
expect(store.getState().folderWorkspaces).toEqual([localFolderWorkspace])
|
||||
})
|
||||
|
||||
it('does not repeat offline runtime compatibility probes across startup catalog loads', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
runtimeEnvironmentTransportCall.mockResolvedValue({
|
||||
id: 'status',
|
||||
ok: false,
|
||||
error: { code: 'runtime_unavailable', message: 'offline' },
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
})
|
||||
const store = createTestStore()
|
||||
store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never })
|
||||
const restoredFolderKey = folderWorkspaceKey('remote-folder')
|
||||
store.setState({
|
||||
restoredRuntimeHostIdByWorkspaceSessionKey: {
|
||||
[restoredFolderKey]: 'runtime:env-1'
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
await store.getState().fetchReposForAllHosts()
|
||||
await store.getState().fetchProjectGroupsForAllHosts()
|
||||
await store.getState().fetchFolderWorkspacesForAllHosts()
|
||||
|
||||
expect(store.getState().repos).toEqual([{ ...localRepo, executionHostId: 'local' }])
|
||||
expect(store.getState().projectGroups).toEqual([
|
||||
{ ...localProjectGroup, executionHostId: 'local' }
|
||||
])
|
||||
expect(store.getState().folderWorkspaces).toEqual([localFolderWorkspace])
|
||||
expect(runtimeEnvironmentTransportCall.mock.calls.map((call) => call[0].method)).toEqual([
|
||||
'status.get'
|
||||
])
|
||||
expect(store.getState().restoredRuntimeHostIdByWorkspaceSessionKey).toEqual({
|
||||
[restoredFolderKey]: 'runtime:env-1'
|
||||
})
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
type RuntimeEnvironmentCallRequest
|
||||
} from '../../runtime/runtime-compatibility-test-fixture'
|
||||
import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client'
|
||||
import { getSetupScriptPromptDismissalKey } from '../../lib/setup-script-prompt'
|
||||
|
||||
const localRepo: Repo = {
|
||||
id: 'local-repo',
|
||||
|
|
@ -684,44 +685,109 @@ describe('fetchReposForAllHosts', () => {
|
|||
expect(store.getState().repos.map((repo) => repo.id)).toEqual(['local-repo'])
|
||||
})
|
||||
|
||||
it('loads project groups and folder workspaces for every host', async () => {
|
||||
it('can load only the local catalog slice for first-paint startup', async () => {
|
||||
const store = createTestStore()
|
||||
store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never })
|
||||
|
||||
await store.getState().fetchProjectGroupsForAllHosts()
|
||||
await store.getState().fetchFolderWorkspacesForAllHosts()
|
||||
|
||||
expect(store.getState().projectGroups).toEqual([
|
||||
{ ...localProjectGroup, executionHostId: 'local' },
|
||||
{ ...remoteProjectGroup, executionHostId: 'runtime:env-1' }
|
||||
])
|
||||
expect(store.getState().folderWorkspaces.map((workspace) => workspace.id)).toEqual([
|
||||
'local-folder',
|
||||
'remote-folder'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps local project groups and folder workspaces when a runtime is unreachable', async () => {
|
||||
runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
|
||||
if (args.method === 'projectGroup.list' || args.method === 'folderWorkspace.list') {
|
||||
throw new Error('runtime_unreachable')
|
||||
}
|
||||
return {
|
||||
id: 'rpc-other',
|
||||
ok: true,
|
||||
result: { repos: [], projects: [], setups: [] },
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
}
|
||||
})
|
||||
const store = createTestStore()
|
||||
store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never })
|
||||
|
||||
await store.getState().fetchProjectGroupsForAllHosts()
|
||||
await store.getState().fetchFolderWorkspacesForAllHosts()
|
||||
await store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' })
|
||||
await store.getState().fetchProjectGroupsForAllHosts({ remoteHosts: 'skip' })
|
||||
await store.getState().fetchFolderWorkspacesForAllHosts({ remoteHosts: 'skip' })
|
||||
|
||||
expect(runtimeEnvironmentsList).not.toHaveBeenCalled()
|
||||
expect(runtimeEnvironmentTransportCall).not.toHaveBeenCalled()
|
||||
expect(store.getState().repos).toEqual([{ ...localRepo, executionHostId: 'local' }])
|
||||
expect(store.getState().projectGroups).toEqual([
|
||||
{ ...localProjectGroup, executionHostId: 'local' }
|
||||
])
|
||||
expect(store.getState().folderWorkspaces).toEqual([localFolderWorkspace])
|
||||
})
|
||||
|
||||
it('preserves remote repo filters during first-paint local catalog refresh', async () => {
|
||||
const store = createTestStore()
|
||||
const remoteDismissalKey = getSetupScriptPromptDismissalKey('remote-repo')
|
||||
const staleDismissalKey = getSetupScriptPromptDismissalKey('stale-repo')
|
||||
store.setState({
|
||||
activeRepoId: 'remote-repo',
|
||||
filterRepoIds: ['remote-repo', 'stale-repo'],
|
||||
setupScriptPromptDismissedRepoIds: [remoteDismissalKey, staleDismissalKey],
|
||||
trustedOrcaHooks: {
|
||||
'remote-repo': { all: { approvedAt: 1 } },
|
||||
'stale-repo': { all: { approvedAt: 2 } }
|
||||
}
|
||||
})
|
||||
|
||||
await store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' })
|
||||
|
||||
expect(store.getState().activeRepoId).toBe('remote-repo')
|
||||
expect(store.getState().filterRepoIds).toEqual(['remote-repo', 'stale-repo'])
|
||||
expect(store.getState().setupScriptPromptDismissedRepoIds).toEqual([
|
||||
remoteDismissalKey,
|
||||
staleDismissalKey
|
||||
])
|
||||
expect(store.getState().trustedOrcaHooks).toEqual({
|
||||
'remote-repo': { all: { approvedAt: 1 } },
|
||||
'stale-repo': { all: { approvedAt: 2 } }
|
||||
})
|
||||
|
||||
await store.getState().fetchReposForAllHosts()
|
||||
|
||||
expect(store.getState().activeRepoId).toBe('remote-repo')
|
||||
expect(store.getState().filterRepoIds).toEqual(['remote-repo'])
|
||||
expect(store.getState().setupScriptPromptDismissedRepoIds).toEqual([remoteDismissalKey])
|
||||
expect(store.getState().trustedOrcaHooks).toEqual({
|
||||
'remote-repo': { all: { approvedAt: 1 } }
|
||||
})
|
||||
})
|
||||
|
||||
it('starts remote repo catalog loads concurrently for all configured runtimes', async () => {
|
||||
runtimeEnvironmentsList.mockResolvedValue([
|
||||
{ id: 'env-1', name: 'first' },
|
||||
{ id: 'env-2', name: 'second' }
|
||||
])
|
||||
const firstStatusResolvers = new Map<string, (value: unknown) => void>()
|
||||
let resolveBothStatusProbes = (): void => {}
|
||||
const bothStatusProbesStarted = new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() => reject(new Error('Timed out waiting for both runtime probes')),
|
||||
1_000
|
||||
)
|
||||
resolveBothStatusProbes = () => {
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
runtimeEnvironmentTransportCall.mockImplementation(
|
||||
(args: RuntimeEnvironmentCallRequest & { selector?: string }) => {
|
||||
if (
|
||||
args.method === 'status.get' &&
|
||||
args.selector &&
|
||||
!firstStatusResolvers.has(args.selector)
|
||||
) {
|
||||
return new Promise((resolve) => {
|
||||
firstStatusResolvers.set(args.selector!, resolve)
|
||||
if (firstStatusResolvers.size === 2) {
|
||||
resolveBothStatusProbes()
|
||||
}
|
||||
})
|
||||
}
|
||||
return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args)
|
||||
}
|
||||
)
|
||||
const store = createTestStore()
|
||||
|
||||
const load = store.getState().fetchReposForAllHosts()
|
||||
await bothStatusProbesStarted
|
||||
|
||||
expect([...firstStatusResolvers.keys()].sort()).toEqual(['env-1', 'env-2'])
|
||||
for (const resolve of firstStatusResolvers.values()) {
|
||||
resolve(createCompatibleRuntimeStatusResponseIfNeeded({ method: 'status.get' }))
|
||||
}
|
||||
await load
|
||||
|
||||
expect(
|
||||
store
|
||||
.getState()
|
||||
.repos.map((repo) => `${repo.id}:${repo.executionHostId}`)
|
||||
.sort()
|
||||
).toEqual(['local-repo:local', 'remote-repo:runtime:env-1', 'remote-repo:runtime:env-2'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ import {
|
|||
toSshExecutionHostId
|
||||
} from '../../../../shared/execution-host'
|
||||
import { cleanupEphemeralVmRuntimesForDeleted } from '@/lib/ephemeral-vm-runtime-cleanup'
|
||||
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import { formatFolderWorkspaceCreateError } from '../../lib/folder-workspace-path-status'
|
||||
|
||||
const ERROR_TOAST_DURATION = 60_000
|
||||
|
|
@ -126,6 +126,10 @@ export type DeleteProjectGroupWithContainedProjectsOptions = {
|
|||
removeContainedProjects: boolean
|
||||
}
|
||||
|
||||
type AllHostCatalogFetchOptions = {
|
||||
remoteHosts?: 'include' | 'skip'
|
||||
}
|
||||
|
||||
export type ProjectRemovalFailure = {
|
||||
projectId: string
|
||||
reason: string
|
||||
|
|
@ -830,6 +834,93 @@ function mergeFetchedFolderWorkspacesForHost({
|
|||
return mergeById(preserved, fetched)
|
||||
}
|
||||
|
||||
type FetchedRepoCatalog = {
|
||||
repos: Repo[]
|
||||
projectHostSetupCompatibility: ProjectHostSetupProjection
|
||||
hostId: ReturnType<typeof getRuntimeTargetHostId>
|
||||
}
|
||||
|
||||
type FetchedProjectGroupCatalog = {
|
||||
projectGroups: ProjectGroup[]
|
||||
hostId: ReturnType<typeof getRuntimeTargetHostId>
|
||||
}
|
||||
|
||||
type FetchedFolderWorkspaceCatalog = {
|
||||
folderWorkspaces: FolderWorkspace[]
|
||||
hostId: ReturnType<typeof getRuntimeTargetHostId>
|
||||
}
|
||||
|
||||
async function fetchRepoCatalogForTarget(
|
||||
target: ReturnType<typeof getActiveRuntimeTarget>
|
||||
): Promise<FetchedRepoCatalog> {
|
||||
const fetchedRepos =
|
||||
target.kind === 'local'
|
||||
? await window.api.repos.list()
|
||||
: (
|
||||
await callRuntimeRpc<{ repos: Repo[] }>(target, 'repo.list', undefined, {
|
||||
timeoutMs: 15_000,
|
||||
reuseRecentCompatibilityFailure: true
|
||||
})
|
||||
).repos
|
||||
const repos = fetchedRepos.map((repo) => repoWithFetchedOwner(repo, target))
|
||||
return {
|
||||
repos,
|
||||
projectHostSetupCompatibility: await fetchProjectHostSetupCompatibility(target, repos),
|
||||
hostId: getRuntimeTargetHostId(target)
|
||||
}
|
||||
}
|
||||
|
||||
function mergeFetchedRepoCatalog(
|
||||
catalog: FetchedRepoCatalog,
|
||||
currentRepos: readonly Repo[]
|
||||
): {
|
||||
repos: Repo[]
|
||||
projectCompatibility: Pick<RepoSlice, 'projects' | 'projectHostSetups'>
|
||||
hostId: ReturnType<typeof getRuntimeTargetHostId>
|
||||
} {
|
||||
const repos = mergeFetchedReposForHost(currentRepos, catalog.repos, catalog.hostId)
|
||||
const projectCompatibility = mergeProjectHostSetupCompatibility(
|
||||
projectCompatibilityFromRepos(repos),
|
||||
catalog.projectHostSetupCompatibility
|
||||
)
|
||||
return { repos, projectCompatibility, hostId: catalog.hostId }
|
||||
}
|
||||
|
||||
function filterTrustedOrcaHooksToValidRepos(
|
||||
trust: AppState['trustedOrcaHooks'],
|
||||
validRepoIds: Set<string>
|
||||
): AppState['trustedOrcaHooks'] {
|
||||
const next: AppState['trustedOrcaHooks'] = {}
|
||||
for (const [repoId, entry] of Object.entries(trust)) {
|
||||
if (validRepoIds.has(repoId)) {
|
||||
next[repoId] = entry
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function clearRestoredFolderWorkspaceSessionOwners(
|
||||
owners: AppState['restoredRuntimeHostIdByWorkspaceSessionKey'] | undefined,
|
||||
state: Pick<AppState, 'folderWorkspaces' | 'projectGroups'>
|
||||
): AppState['restoredRuntimeHostIdByWorkspaceSessionKey'] {
|
||||
const next: AppState['restoredRuntimeHostIdByWorkspaceSessionKey'] = {}
|
||||
for (const [key, hostId] of Object.entries(owners ?? {})) {
|
||||
const scope = parseWorkspaceKey(key)
|
||||
if (scope?.type !== 'folder') {
|
||||
next[key] = hostId
|
||||
continue
|
||||
}
|
||||
const workspace = state.folderWorkspaces.find((entry) => entry.id === scope.folderWorkspaceId)
|
||||
if (workspace && !state.projectGroups.some((group) => group.id === workspace.projectGroupId)) {
|
||||
// Why: folder workspace ownership is resolved through its project group.
|
||||
// If that catalog is still missing, keep the restored host owner so a
|
||||
// session write before the next retry does not move runtime tabs local.
|
||||
next[key] = hostId
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
async function fetchReposForTarget(
|
||||
target: ReturnType<typeof getActiveRuntimeTarget>,
|
||||
currentRepos: readonly Repo[]
|
||||
|
|
@ -838,49 +929,87 @@ async function fetchReposForTarget(
|
|||
projectCompatibility: Pick<RepoSlice, 'projects' | 'projectHostSetups'>
|
||||
hostId: ReturnType<typeof getRuntimeTargetHostId>
|
||||
}> {
|
||||
const fetchedRepos =
|
||||
target.kind === 'local'
|
||||
? await window.api.repos.list()
|
||||
: (
|
||||
await callRuntimeRpc<{ repos: Repo[] }>(target, 'repo.list', undefined, {
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
).repos
|
||||
const hostId = getRuntimeTargetHostId(target)
|
||||
const repos = fetchedRepos.map((repo) => repoWithFetchedOwner(repo, target))
|
||||
const fetchedProjectCompatibility = await fetchProjectHostSetupCompatibility(target, repos)
|
||||
const reconciledRepos = mergeFetchedReposForHost(currentRepos, repos, hostId)
|
||||
const projectCompatibility =
|
||||
target.kind === 'local'
|
||||
? mergeProjectHostSetupCompatibility(
|
||||
projectCompatibilityFromRepos(reconciledRepos),
|
||||
fetchedProjectCompatibility
|
||||
)
|
||||
: mergeProjectHostSetupCompatibility(
|
||||
projectCompatibilityFromRepos(reconciledRepos),
|
||||
fetchedProjectCompatibility
|
||||
)
|
||||
return mergeFetchedRepoCatalog(await fetchRepoCatalogForTarget(target), currentRepos)
|
||||
}
|
||||
|
||||
return { repos: reconciledRepos, projectCompatibility, hostId }
|
||||
async function fetchProjectGroupCatalogForTarget(
|
||||
target: ReturnType<typeof getActiveRuntimeTarget>
|
||||
): Promise<FetchedProjectGroupCatalog> {
|
||||
const fetchedGroups =
|
||||
target.kind === 'local'
|
||||
? await window.api.projectGroups.list()
|
||||
: (
|
||||
await callRuntimeRpc<{ groups: ProjectGroup[] }>(target, 'projectGroup.list', undefined, {
|
||||
timeoutMs: 15_000,
|
||||
reuseRecentCompatibilityFailure: true
|
||||
})
|
||||
).groups
|
||||
return {
|
||||
projectGroups: fetchedGroups.map((group) => projectGroupWithFetchedOwner(group, target)),
|
||||
hostId: getRuntimeTargetHostId(target)
|
||||
}
|
||||
}
|
||||
|
||||
function mergeFetchedProjectGroupCatalog(
|
||||
catalog: FetchedProjectGroupCatalog,
|
||||
currentProjectGroups: readonly ProjectGroup[]
|
||||
): { projectGroups: ProjectGroup[]; hostId: ReturnType<typeof getRuntimeTargetHostId> } {
|
||||
return {
|
||||
projectGroups: mergeFetchedProjectGroupsForHost(
|
||||
currentProjectGroups,
|
||||
catalog.projectGroups,
|
||||
catalog.hostId
|
||||
),
|
||||
hostId: catalog.hostId
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProjectGroupsForTarget(
|
||||
target: ReturnType<typeof getActiveRuntimeTarget>,
|
||||
currentProjectGroups: readonly ProjectGroup[]
|
||||
): Promise<{ projectGroups: ProjectGroup[]; hostId: ReturnType<typeof getRuntimeTargetHostId> }> {
|
||||
const fetchedGroups =
|
||||
return mergeFetchedProjectGroupCatalog(
|
||||
await fetchProjectGroupCatalogForTarget(target),
|
||||
currentProjectGroups
|
||||
)
|
||||
}
|
||||
|
||||
async function fetchFolderWorkspaceCatalogForTarget(
|
||||
target: ReturnType<typeof getActiveRuntimeTarget>
|
||||
): Promise<FetchedFolderWorkspaceCatalog> {
|
||||
const fetchedFolderWorkspaces =
|
||||
target.kind === 'local'
|
||||
? await window.api.projectGroups.list()
|
||||
? await window.api.folderWorkspaces.list()
|
||||
: (
|
||||
await callRuntimeRpc<{ groups: ProjectGroup[] }>(target, 'projectGroup.list', undefined, {
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
).groups
|
||||
const hostId = getRuntimeTargetHostId(target)
|
||||
const ownedGroups = fetchedGroups.map((group) => projectGroupWithFetchedOwner(group, target))
|
||||
await callRuntimeRpc<{ folderWorkspaces: FolderWorkspace[] }>(
|
||||
target,
|
||||
'folderWorkspace.list',
|
||||
undefined,
|
||||
{ timeoutMs: 15_000, reuseRecentCompatibilityFailure: true }
|
||||
)
|
||||
).folderWorkspaces
|
||||
return {
|
||||
projectGroups: mergeFetchedProjectGroupsForHost(currentProjectGroups, ownedGroups, hostId),
|
||||
hostId
|
||||
folderWorkspaces: fetchedFolderWorkspaces,
|
||||
hostId: getRuntimeTargetHostId(target)
|
||||
}
|
||||
}
|
||||
|
||||
function mergeFetchedFolderWorkspaceCatalog(
|
||||
catalog: FetchedFolderWorkspaceCatalog,
|
||||
currentFolderWorkspaces: readonly FolderWorkspace[],
|
||||
projectGroups: readonly ProjectGroup[]
|
||||
): {
|
||||
folderWorkspaces: FolderWorkspace[]
|
||||
hostId: ReturnType<typeof getRuntimeTargetHostId>
|
||||
} {
|
||||
return {
|
||||
folderWorkspaces: mergeFetchedFolderWorkspacesForHost({
|
||||
previous: currentFolderWorkspaces,
|
||||
fetched: catalog.folderWorkspaces,
|
||||
projectGroups,
|
||||
hostId: catalog.hostId
|
||||
}),
|
||||
hostId: catalog.hostId
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -892,27 +1021,11 @@ async function fetchFolderWorkspacesForTarget(
|
|||
folderWorkspaces: FolderWorkspace[]
|
||||
hostId: ReturnType<typeof getRuntimeTargetHostId>
|
||||
}> {
|
||||
const fetchedFolderWorkspaces =
|
||||
target.kind === 'local'
|
||||
? await window.api.folderWorkspaces.list()
|
||||
: (
|
||||
await callRuntimeRpc<{ folderWorkspaces: FolderWorkspace[] }>(
|
||||
target,
|
||||
'folderWorkspace.list',
|
||||
undefined,
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
).folderWorkspaces
|
||||
const hostId = getRuntimeTargetHostId(target)
|
||||
return {
|
||||
folderWorkspaces: mergeFetchedFolderWorkspacesForHost({
|
||||
previous: currentFolderWorkspaces,
|
||||
fetched: fetchedFolderWorkspaces,
|
||||
projectGroups,
|
||||
hostId
|
||||
}),
|
||||
hostId
|
||||
}
|
||||
return mergeFetchedFolderWorkspaceCatalog(
|
||||
await fetchFolderWorkspaceCatalogForTarget(target),
|
||||
currentFolderWorkspaces,
|
||||
projectGroups
|
||||
)
|
||||
}
|
||||
|
||||
async function listRuntimeEnvironmentsForAllHostLoad(): Promise<{ id: string }[]> {
|
||||
|
|
@ -1155,12 +1268,12 @@ export type RepoSlice = {
|
|||
// Monotonic sequence so an overlapping fetchRepos can drop its own stale result (#7020).
|
||||
reposFetchGeneration: number
|
||||
fetchRepos: () => Promise<void>
|
||||
fetchReposForAllHosts: () => Promise<void>
|
||||
fetchReposForAllHosts: (options?: AllHostCatalogFetchOptions) => Promise<void>
|
||||
fetchRuntimeEnvironmentRepos: (environmentId: string) => Promise<Repo[]>
|
||||
fetchProjectGroups: () => Promise<void>
|
||||
fetchProjectGroupsForAllHosts: () => Promise<void>
|
||||
fetchProjectGroupsForAllHosts: (options?: AllHostCatalogFetchOptions) => Promise<void>
|
||||
fetchFolderWorkspaces: () => Promise<void>
|
||||
fetchFolderWorkspacesForAllHosts: () => Promise<void>
|
||||
fetchFolderWorkspacesForAllHosts: (options?: AllHostCatalogFetchOptions) => Promise<void>
|
||||
addRepo: () => Promise<Repo | null>
|
||||
addRepoPath: (
|
||||
path: string,
|
||||
|
|
@ -1366,7 +1479,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
|||
}
|
||||
},
|
||||
|
||||
fetchReposForAllHosts: async () => {
|
||||
fetchReposForAllHosts: async (options) => {
|
||||
// Why: a cold start that restores a remote workspace re-activates that
|
||||
// remote runtime environment, and fetching only the active host hides every
|
||||
// other host's repos (notably all local repos), which reads as "my projects
|
||||
|
|
@ -1374,9 +1487,10 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
|||
// sidebar "All hosts" scope shows them together regardless of which
|
||||
// environment is active. Each host fails soft: an unreachable/disconnected
|
||||
// host is skipped without blocking the others.
|
||||
const applyResult = (result: Awaited<ReturnType<typeof fetchReposForTarget>>): void => {
|
||||
const validRepoIds = new Set(result.repos.map((repo) => repo.id))
|
||||
const applyCatalog = (catalog: FetchedRepoCatalog): void => {
|
||||
let hostRepos: Repo[] = []
|
||||
set((s) => {
|
||||
const result = mergeFetchedRepoCatalog(catalog, s.repos)
|
||||
const mergedProjectCompatibility = mergeFetchedProjectCompatibilityForHost({
|
||||
previous: {
|
||||
projects: s.projects,
|
||||
|
|
@ -1386,48 +1500,71 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
|||
repos: result.repos,
|
||||
hostId: result.hostId
|
||||
})
|
||||
hostRepos = result.repos.filter((repo) => getRepoExecutionHostId(repo) === result.hostId)
|
||||
return {
|
||||
repos: result.repos,
|
||||
...mergedProjectCompatibility,
|
||||
folderWorkspacePathStatuses: {},
|
||||
activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null,
|
||||
filterRepoIds: s.filterRepoIds.filter((projectId) => validRepoIds.has(projectId)),
|
||||
setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos(
|
||||
s.setupScriptPromptDismissedRepoIds,
|
||||
validRepoIds
|
||||
)
|
||||
activeRepoId: s.activeRepoId,
|
||||
filterRepoIds: s.filterRepoIds,
|
||||
setupScriptPromptDismissedRepoIds: s.setupScriptPromptDismissedRepoIds
|
||||
}
|
||||
})
|
||||
// Why: preserve the safe-auto fork sync that fetchRepos /
|
||||
// fetchRuntimeEnvironmentRepos schedule after merging each host, so
|
||||
// cold-start (which now routes through here) keeps updating safe-auto forks.
|
||||
scheduleSafeAutoForkSync(
|
||||
get,
|
||||
result.repos.filter((repo) => getRepoExecutionHostId(repo) === result.hostId)
|
||||
)
|
||||
scheduleSafeAutoForkSync(get, hostRepos)
|
||||
}
|
||||
const validateRepoScopedUi = (): void => {
|
||||
set((s) => {
|
||||
const validRepoIds = new Set(s.repos.map((repo) => repo.id))
|
||||
return {
|
||||
activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null,
|
||||
filterRepoIds: s.filterRepoIds.filter((projectId) => validRepoIds.has(projectId)),
|
||||
setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos(
|
||||
s.setupScriptPromptDismissedRepoIds,
|
||||
validRepoIds
|
||||
),
|
||||
trustedOrcaHooks: filterTrustedOrcaHooksToValidRepos(s.trustedOrcaHooks, validRepoIds)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Local first so local repos are present even if a remote fetch stalls.
|
||||
let failed = false
|
||||
try {
|
||||
applyResult(await fetchReposForTarget({ kind: 'local' }, get().repos))
|
||||
applyCatalog(await fetchRepoCatalogForTarget({ kind: 'local' }))
|
||||
} catch (err) {
|
||||
failed = true
|
||||
console.error('Failed to fetch local repos for all-host load:', err)
|
||||
}
|
||||
if (options?.remoteHosts === 'skip') {
|
||||
return
|
||||
}
|
||||
|
||||
const environments = await listRuntimeEnvironmentsForAllHostLoad()
|
||||
|
||||
// Sequential to avoid concurrent set() races on the merged repos array.
|
||||
for (const environment of environments) {
|
||||
try {
|
||||
applyResult(
|
||||
await fetchReposForTarget(
|
||||
{ kind: 'environment', environmentId: environment.id },
|
||||
get().repos
|
||||
// Why: unreachable remotes can spend the full connect timeout; merge each
|
||||
// resolved host through the state updater so parallel loads do not clobber.
|
||||
await Promise.all(
|
||||
environments.map(async (environment) => {
|
||||
try {
|
||||
applyCatalog(
|
||||
await fetchRepoCatalogForTarget({
|
||||
kind: 'environment',
|
||||
environmentId: environment.id
|
||||
})
|
||||
)
|
||||
)
|
||||
} catch (err) {
|
||||
console.warn(`Skipped repos for runtime environment ${environment.id}:`, err)
|
||||
}
|
||||
} catch (err) {
|
||||
failed = true
|
||||
console.warn(`Skipped repos for runtime environment ${environment.id}:`, err)
|
||||
}
|
||||
})
|
||||
)
|
||||
// Why: first-paint startup intentionally loads only local repos before
|
||||
// remotes answer. Validate repo-scoped UI only once every configured host has
|
||||
// answered; otherwise an offline runtime would erase its saved filters.
|
||||
if (!failed) {
|
||||
validateRepoScopedUi()
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -1444,35 +1581,40 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
|||
}
|
||||
},
|
||||
|
||||
fetchProjectGroupsForAllHosts: async () => {
|
||||
fetchProjectGroupsForAllHosts: async (options) => {
|
||||
// Why: startup renders an all-host sidebar; replacing groups with only the
|
||||
// active host would leave repos from other hosts visible but ungrouped.
|
||||
const applyResult = (result: Awaited<ReturnType<typeof fetchProjectGroupsForTarget>>): void => {
|
||||
set({
|
||||
projectGroups: result.projectGroups,
|
||||
const applyCatalog = (catalog: FetchedProjectGroupCatalog): void => {
|
||||
set((s) => ({
|
||||
projectGroups: mergeFetchedProjectGroupCatalog(catalog, s.projectGroups).projectGroups,
|
||||
folderWorkspacePathStatuses: {}
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
try {
|
||||
applyResult(await fetchProjectGroupsForTarget({ kind: 'local' }, get().projectGroups))
|
||||
applyCatalog(await fetchProjectGroupCatalogForTarget({ kind: 'local' }))
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch local project groups for all-host load:', err)
|
||||
}
|
||||
if (options?.remoteHosts === 'skip') {
|
||||
return
|
||||
}
|
||||
|
||||
const environments = await listRuntimeEnvironmentsForAllHostLoad()
|
||||
for (const environment of environments) {
|
||||
try {
|
||||
applyResult(
|
||||
await fetchProjectGroupsForTarget(
|
||||
{ kind: 'environment', environmentId: environment.id },
|
||||
get().projectGroups
|
||||
await Promise.all(
|
||||
environments.map(async (environment) => {
|
||||
try {
|
||||
applyCatalog(
|
||||
await fetchProjectGroupCatalogForTarget({
|
||||
kind: 'environment',
|
||||
environmentId: environment.id
|
||||
})
|
||||
)
|
||||
)
|
||||
} catch (err) {
|
||||
console.warn(`Skipped project groups for runtime environment ${environment.id}:`, err)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Skipped project groups for runtime environment ${environment.id}:`, err)
|
||||
}
|
||||
})
|
||||
)
|
||||
},
|
||||
|
||||
fetchFolderWorkspaces: async () => {
|
||||
|
|
@ -1489,43 +1631,54 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
|||
}
|
||||
},
|
||||
|
||||
fetchFolderWorkspacesForAllHosts: async () => {
|
||||
fetchFolderWorkspacesForAllHosts: async (options) => {
|
||||
// Why: folder workspaces are owned through their project groups, so startup
|
||||
// must fetch groups first and then merge each host's folder slice.
|
||||
const applyResult = (
|
||||
result: Awaited<ReturnType<typeof fetchFolderWorkspacesForTarget>>
|
||||
): void => {
|
||||
set({
|
||||
folderWorkspaces: result.folderWorkspaces,
|
||||
const applyCatalog = (catalog: FetchedFolderWorkspaceCatalog): void => {
|
||||
set((s) => ({
|
||||
folderWorkspaces: mergeFetchedFolderWorkspaceCatalog(
|
||||
catalog,
|
||||
s.folderWorkspaces,
|
||||
s.projectGroups
|
||||
).folderWorkspaces,
|
||||
folderWorkspacePathStatuses: {}
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
let failed = false
|
||||
try {
|
||||
applyResult(
|
||||
await fetchFolderWorkspacesForTarget(
|
||||
{ kind: 'local' },
|
||||
get().folderWorkspaces,
|
||||
get().projectGroups
|
||||
)
|
||||
)
|
||||
applyCatalog(await fetchFolderWorkspaceCatalogForTarget({ kind: 'local' }))
|
||||
} catch (err) {
|
||||
failed = true
|
||||
console.error('Failed to fetch local folder workspaces for all-host load:', err)
|
||||
}
|
||||
if (options?.remoteHosts === 'skip') {
|
||||
return
|
||||
}
|
||||
|
||||
const environments = await listRuntimeEnvironmentsForAllHostLoad()
|
||||
for (const environment of environments) {
|
||||
try {
|
||||
applyResult(
|
||||
await fetchFolderWorkspacesForTarget(
|
||||
{ kind: 'environment', environmentId: environment.id },
|
||||
get().folderWorkspaces,
|
||||
get().projectGroups
|
||||
await Promise.all(
|
||||
environments.map(async (environment) => {
|
||||
try {
|
||||
applyCatalog(
|
||||
await fetchFolderWorkspaceCatalogForTarget({
|
||||
kind: 'environment',
|
||||
environmentId: environment.id
|
||||
})
|
||||
)
|
||||
} catch (err) {
|
||||
failed = true
|
||||
console.warn(`Skipped folder workspaces for runtime environment ${environment.id}:`, err)
|
||||
}
|
||||
})
|
||||
)
|
||||
if (!failed) {
|
||||
set((s) => ({
|
||||
restoredRuntimeHostIdByWorkspaceSessionKey: clearRestoredFolderWorkspaceSessionOwners(
|
||||
s.restoredRuntimeHostIdByWorkspaceSessionKey,
|
||||
s
|
||||
)
|
||||
} catch (err) {
|
||||
console.warn(`Skipped folder workspaces for runtime environment ${environment.id}:`, err)
|
||||
}
|
||||
}))
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|||
import type * as AgentStatusModule from '@/lib/agent-status'
|
||||
import type { BrowserTab, DetectedWorktreeListResult, Worktree } from '../../../../shared/types'
|
||||
import { isTerminalLeafId } from '../../../../shared/stable-pane-id'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import {
|
||||
FLOATING_TERMINAL_WORKTREE_ID,
|
||||
getDefaultWorkspaceSession
|
||||
} from '../../../../shared/constants'
|
||||
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
|
||||
// Mock sonner (imported by repos.ts)
|
||||
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
|
||||
|
|
@ -531,6 +535,143 @@ describe('hydrateWorkspaceSession', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('restored folder workspace hydration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('keeps runtime folder workspace tabs, files, and browsers before remote catalogs load', () => {
|
||||
const store = createTestStore()
|
||||
const folderKey = folderWorkspaceKey('remote-folder')
|
||||
const groupId = 'group-folder'
|
||||
const editorFileId = '/srv/app/src/App.tsx'
|
||||
const session = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
activeWorkspaceKey: folderKey,
|
||||
activeWorktreeId: folderKey,
|
||||
activeTabId: 'terminal-folder',
|
||||
tabsByWorktree: {
|
||||
[folderKey]: [makeTab({ id: 'terminal-folder', worktreeId: folderKey })]
|
||||
},
|
||||
openFilesByWorktree: {
|
||||
[folderKey]: [
|
||||
{
|
||||
filePath: editorFileId,
|
||||
relativePath: 'src/App.tsx',
|
||||
worktreeId: folderKey,
|
||||
language: 'typescript'
|
||||
}
|
||||
]
|
||||
},
|
||||
activeFileIdByWorktree: { [folderKey]: editorFileId },
|
||||
browserTabsByWorktree: {
|
||||
[folderKey]: [
|
||||
makeBrowserTab({
|
||||
id: 'browser-folder',
|
||||
worktreeId: folderKey,
|
||||
url: 'https://example.com'
|
||||
})
|
||||
]
|
||||
},
|
||||
browserPagesByWorkspace: {
|
||||
'browser-folder': [
|
||||
{
|
||||
id: 'browser-page-folder',
|
||||
workspaceId: 'browser-folder',
|
||||
worktreeId: folderKey,
|
||||
url: 'https://example.com',
|
||||
title: 'Example',
|
||||
loading: false,
|
||||
faviconUrl: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
activeBrowserTabIdByWorktree: { [folderKey]: 'browser-folder' },
|
||||
activeTabTypeByWorktree: { [folderKey]: 'browser' as const },
|
||||
unifiedTabs: {
|
||||
[folderKey]: [
|
||||
{
|
||||
id: 'terminal-folder',
|
||||
entityId: 'terminal-folder',
|
||||
groupId,
|
||||
worktreeId: folderKey,
|
||||
contentType: 'terminal' as const,
|
||||
label: 'Terminal',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
},
|
||||
{
|
||||
id: editorFileId,
|
||||
entityId: editorFileId,
|
||||
groupId,
|
||||
worktreeId: folderKey,
|
||||
contentType: 'editor' as const,
|
||||
label: 'App.tsx',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 1,
|
||||
createdAt: 2
|
||||
},
|
||||
{
|
||||
id: 'browser-folder',
|
||||
entityId: 'browser-folder',
|
||||
groupId,
|
||||
worktreeId: folderKey,
|
||||
contentType: 'browser' as const,
|
||||
label: 'Example',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 2,
|
||||
createdAt: 3
|
||||
}
|
||||
]
|
||||
},
|
||||
tabGroups: {
|
||||
[folderKey]: [
|
||||
{
|
||||
id: groupId,
|
||||
worktreeId: folderKey,
|
||||
activeTabId: 'browser-folder',
|
||||
tabOrder: ['terminal-folder', editorFileId, 'browser-folder'],
|
||||
recentTabIds: ['terminal-folder', editorFileId, 'browser-folder']
|
||||
}
|
||||
]
|
||||
},
|
||||
activeGroupIdByWorktree: { [folderKey]: groupId }
|
||||
}
|
||||
const options = {
|
||||
additionalValidWorkspaceKeys: [folderKey],
|
||||
runtimeHostIdByWorkspaceSessionKey: { [folderKey]: 'runtime:env-1' as const }
|
||||
}
|
||||
|
||||
store.getState().hydrateWorkspaceSession(session, options)
|
||||
store.getState().hydrateTabsSession(session, options)
|
||||
store.getState().hydrateEditorSession(session, options)
|
||||
store.getState().hydrateBrowserSession(session, options)
|
||||
|
||||
const state = store.getState()
|
||||
expect(state.activeWorktreeId).toBe(folderKey)
|
||||
expect(state.activeWorkspaceKey).toBe(folderKey)
|
||||
expect(state.tabsByWorktree[folderKey]?.map((tab) => tab.id)).toEqual(['terminal-folder'])
|
||||
expect(state.unifiedTabsByWorktree[folderKey]?.map((tab) => tab.id)).toEqual([
|
||||
'terminal-folder',
|
||||
editorFileId,
|
||||
'browser-folder'
|
||||
])
|
||||
expect(state.openFiles.map((file) => file.worktreeId)).toEqual([folderKey])
|
||||
expect(state.activeFileIdByWorktree[folderKey]).toBe(editorFileId)
|
||||
expect(state.browserTabsByWorktree[folderKey]?.map((tab) => tab.id)).toEqual(['browser-folder'])
|
||||
expect(state.browserPagesByWorkspace['browser-folder']?.[0]?.worktreeId).toBe(folderKey)
|
||||
expect(state.activeTabTypeByWorktree[folderKey]).toBe('browser')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hydrateBrowserSession', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
|
|
|||
|
|
@ -35,6 +35,10 @@ import { createBrowserUuid } from '@/lib/browser-uuid'
|
|||
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import {
|
||||
addAdditionalValidWorkspaceKeys,
|
||||
type WorkspaceSessionHydrationOptions
|
||||
} from '@/lib/workspace-session-hydration-keys'
|
||||
|
||||
export type TabSplitDirection = 'left' | 'right' | 'up' | 'down'
|
||||
|
||||
|
|
@ -172,7 +176,10 @@ export type TabsSlice = {
|
|||
renderableTabCount: number
|
||||
activeRenderableTabId: string | null
|
||||
}
|
||||
hydrateTabsSession: (session: WorkspaceSessionState) => void
|
||||
hydrateTabsSession: (
|
||||
session: WorkspaceSessionState,
|
||||
options?: WorkspaceSessionHydrationOptions
|
||||
) => void
|
||||
}
|
||||
|
||||
// Why: keep the TerminalTab (tabsByWorktree) pin in sync with the unified-tab
|
||||
|
|
@ -2036,7 +2043,7 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
|
|||
}
|
||||
},
|
||||
|
||||
hydrateTabsSession: (session) => {
|
||||
hydrateTabsSession: (session, options) => {
|
||||
const state = get()
|
||||
const validWorktreeIds = new Set(
|
||||
Object.values(state.worktreesByRepo)
|
||||
|
|
@ -2047,6 +2054,7 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
|
|||
for (const workspace of state.folderWorkspaces) {
|
||||
validWorktreeIds.add(folderWorkspaceKey(workspace.id))
|
||||
}
|
||||
addAdditionalValidWorkspaceKeys(validWorktreeIds, options)
|
||||
set(buildHydratedTabState(session, validWorktreeIds))
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -92,7 +92,11 @@ const mockApi = {
|
|||
globalThis.window = { api: mockApi }
|
||||
|
||||
import type { WorkspaceSessionState } from '../../../../shared/types'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import {
|
||||
FLOATING_TERMINAL_WORKTREE_ID,
|
||||
getDefaultWorkspaceSession
|
||||
} from '../../../../shared/constants'
|
||||
import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import { createTestStore, makeLayout, makeTab, makeWorktree, seedStore } from './store-test-helpers'
|
||||
import { canGoBackWorktreeHistory } from './worktree-nav-history'
|
||||
|
||||
|
|
@ -138,6 +142,145 @@ describe('hydrateWorkspaceSession', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('hydrates runtime-owned tabs from host partitions before remote catalogs load', () => {
|
||||
const store = createTestStore()
|
||||
const worktreeId = 'remote-repo::/srv/remote-wt'
|
||||
const session: WorkspaceSessionState = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
activeRepoId: 'remote-repo',
|
||||
activeWorktreeId: worktreeId,
|
||||
activeTabId: 'remote-tab',
|
||||
activeWorktreeIdsOnShutdown: [worktreeId],
|
||||
tabsByWorktree: {
|
||||
[worktreeId]: [
|
||||
makeTab({
|
||||
id: 'remote-tab',
|
||||
worktreeId,
|
||||
ptyId: 'remote-session'
|
||||
})
|
||||
]
|
||||
},
|
||||
remoteSessionIdsByTabId: { 'remote-tab': 'remote-session' }
|
||||
}
|
||||
|
||||
store.getState().hydrateWorkspaceSession(session, {
|
||||
runtimeHostIdByWorkspaceSessionKey: { [worktreeId]: 'runtime:env-1' }
|
||||
})
|
||||
|
||||
expect(store.getState().tabsByWorktree[worktreeId]?.map((tab) => tab.id)).toEqual([
|
||||
'remote-tab'
|
||||
])
|
||||
expect(store.getState().activeWorktreeId).toBe(worktreeId)
|
||||
expect(store.getState().activeRepoId).toBe('remote-repo')
|
||||
expect(store.getState().pendingReconnectWorktreeIds).toEqual([worktreeId])
|
||||
expect(store.getState().pendingReconnectPtyIdByTabId).toEqual({
|
||||
'remote-tab': 'remote-session'
|
||||
})
|
||||
expect(store.getState().repos).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'remote-repo',
|
||||
executionHostId: 'runtime:env-1'
|
||||
})
|
||||
])
|
||||
expect(store.getState().worktreesByRepo['remote-repo']).toEqual([
|
||||
expect.objectContaining({
|
||||
id: worktreeId,
|
||||
hostId: 'runtime:env-1'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('avoids duplicate repo placeholders when a same-id local repo is already loaded', () => {
|
||||
const store = createTestStore()
|
||||
const worktreeId = 'same-repo::/srv/remote-wt'
|
||||
store.setState({
|
||||
repos: [
|
||||
{
|
||||
id: 'same-repo',
|
||||
path: '/Users/me/same-repo',
|
||||
displayName: 'Same repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 1,
|
||||
connectionId: null,
|
||||
executionHostId: 'local'
|
||||
}
|
||||
],
|
||||
worktreesByRepo: {}
|
||||
})
|
||||
const session: WorkspaceSessionState = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
activeRepoId: 'same-repo',
|
||||
activeWorktreeId: worktreeId,
|
||||
activeTabId: 'remote-tab',
|
||||
activeWorktreeIdsOnShutdown: [worktreeId],
|
||||
tabsByWorktree: {
|
||||
[worktreeId]: [
|
||||
makeTab({
|
||||
id: 'remote-tab',
|
||||
worktreeId,
|
||||
ptyId: 'remote-session'
|
||||
})
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
store.getState().hydrateWorkspaceSession(session, {
|
||||
runtimeHostIdByWorkspaceSessionKey: {
|
||||
[worktreeWorkspaceKey(worktreeId)]: 'runtime:env-1'
|
||||
}
|
||||
})
|
||||
|
||||
expect(store.getState().repos.map((repo) => `${repo.id}:${repo.executionHostId}`)).toEqual([
|
||||
'same-repo:local'
|
||||
])
|
||||
expect(store.getState().worktreesByRepo['same-repo']).toEqual([
|
||||
expect.objectContaining({ id: worktreeId, hostId: 'runtime:env-1' })
|
||||
])
|
||||
expect(store.getState().tabsByWorktree[worktreeId]?.map((tab) => tab.id)).toEqual([
|
||||
'remote-tab'
|
||||
])
|
||||
})
|
||||
|
||||
it('hydrates runtime folder workspace tabs before remote folder catalogs load', () => {
|
||||
const store = createTestStore()
|
||||
const folderKey = folderWorkspaceKey('folder-1')
|
||||
const session: WorkspaceSessionState = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
activeWorkspaceKey: folderKey,
|
||||
activeWorktreeId: folderKey,
|
||||
activeTabId: 'remote-folder-tab',
|
||||
activeWorktreeIdsOnShutdown: [folderKey],
|
||||
tabsByWorktree: {
|
||||
[folderKey]: [
|
||||
makeTab({
|
||||
id: 'remote-folder-tab',
|
||||
worktreeId: folderKey,
|
||||
ptyId: 'remote-folder-session'
|
||||
})
|
||||
]
|
||||
},
|
||||
remoteSessionIdsByTabId: { 'remote-folder-tab': 'remote-folder-session' }
|
||||
}
|
||||
|
||||
store.getState().hydrateWorkspaceSession(session, {
|
||||
additionalValidWorkspaceKeys: [folderKey],
|
||||
runtimeHostIdByWorkspaceSessionKey: { [folderKey]: 'runtime:env-1' }
|
||||
})
|
||||
|
||||
expect(store.getState().tabsByWorktree[folderKey]?.map((tab) => tab.id)).toEqual([
|
||||
'remote-folder-tab'
|
||||
])
|
||||
expect(store.getState().activeWorktreeId).toBe(folderKey)
|
||||
expect(store.getState().activeWorkspaceKey).toBe(folderKey)
|
||||
expect(store.getState().pendingReconnectWorktreeIds).toEqual([folderKey])
|
||||
expect(store.getState().pendingReconnectPtyIdByTabId).toEqual({
|
||||
'remote-folder-tab': 'remote-folder-session'
|
||||
})
|
||||
expect(store.getState().restoredRuntimeHostIdByWorkspaceSessionKey).toEqual({
|
||||
[folderKey]: 'runtime:env-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('moves restored active focus from a dead split leaf to a pty-backed sibling', () => {
|
||||
const store = createTestStore()
|
||||
const worktreeId = 'repo1::/wt-1'
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import type { StateCreator } from 'zustand'
|
||||
import type { AppState } from '../types'
|
||||
import type {
|
||||
Repo,
|
||||
SetupSplitDirection,
|
||||
Tab,
|
||||
TerminalLayoutSnapshot,
|
||||
|
|
@ -15,7 +16,11 @@ import type {
|
|||
AgentProviderSessionMetadata,
|
||||
SleepingAgentLaunchConfig
|
||||
} from '../../../../shared/agent-session-resume'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import {
|
||||
DEFAULT_REPO_BADGE_COLOR,
|
||||
FLOATING_TERMINAL_WORKTREE_ID
|
||||
} from '../../../../shared/constants'
|
||||
import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import {
|
||||
folderWorkspaceKey,
|
||||
parseWorkspaceKey,
|
||||
|
|
@ -65,6 +70,10 @@ import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
|
|||
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
|
||||
import type { NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt'
|
||||
import {
|
||||
addAdditionalValidWorkspaceKeys,
|
||||
type WorkspaceSessionHydrationOptions
|
||||
} from '@/lib/workspace-session-hydration-keys'
|
||||
import {
|
||||
collectHibernatedCompletionEvidenceForWorktree,
|
||||
collectSleepingAgentSessionRecordsForWorktree,
|
||||
|
|
@ -120,6 +129,89 @@ function getFallbackTabTitle(tab: TerminalTab, index?: number): string {
|
|||
)
|
||||
}
|
||||
|
||||
function getPathDisplayName(path: string, fallback: string): string {
|
||||
const normalized = path.trim().replace(/[\\/]+$/g, '')
|
||||
const basename = normalized.split(/[\\/]/).findLast(Boolean)?.trim()
|
||||
return basename || fallback
|
||||
}
|
||||
|
||||
function buildRuntimeSessionPlaceholders({
|
||||
repos,
|
||||
runtimeHostIdByWorkspaceSessionKey,
|
||||
worktreesByRepo
|
||||
}: {
|
||||
repos: readonly Repo[]
|
||||
runtimeHostIdByWorkspaceSessionKey: Record<string, ExecutionHostId>
|
||||
worktreesByRepo: Record<string, Worktree[]>
|
||||
}): { repos: Repo[]; worktreesByRepo: Record<string, Worktree[]> } {
|
||||
let nextRepos = repos.slice()
|
||||
let nextWorktreesByRepo = worktreesByRepo
|
||||
for (const workspaceSessionKey of Object.keys(runtimeHostIdByWorkspaceSessionKey)) {
|
||||
const hostId = runtimeHostIdByWorkspaceSessionKey[workspaceSessionKey]
|
||||
if (parseExecutionHostId(hostId)?.kind !== 'runtime') {
|
||||
continue
|
||||
}
|
||||
const workspaceScope = parseWorkspaceKey(workspaceSessionKey)
|
||||
if (workspaceScope?.type === 'folder') {
|
||||
continue
|
||||
}
|
||||
const worktreeId =
|
||||
workspaceScope?.type === 'worktree' ? workspaceScope.worktreeId : workspaceSessionKey
|
||||
const parsed = splitWorktreeId(worktreeId)
|
||||
if (!parsed) {
|
||||
continue
|
||||
}
|
||||
const existingRepo = nextRepos.some((repo) => repo.id === parsed.repoId)
|
||||
if (!existingRepo) {
|
||||
// Why: remote catalogs load after hydration, but host-split session
|
||||
// writes need owner metadata. If any repo with this id already exists,
|
||||
// avoid duplicate ids; the worktree placeholder below carries hostId.
|
||||
nextRepos = [
|
||||
...nextRepos,
|
||||
{
|
||||
id: parsed.repoId,
|
||||
path: parsed.worktreePath,
|
||||
displayName: getPathDisplayName(parsed.worktreePath, parsed.repoId),
|
||||
badgeColor: DEFAULT_REPO_BADGE_COLOR,
|
||||
addedAt: 0,
|
||||
connectionId: null,
|
||||
executionHostId: hostId
|
||||
}
|
||||
]
|
||||
}
|
||||
const current = nextWorktreesByRepo[parsed.repoId] ?? []
|
||||
if (current.some((worktree) => worktree.id === worktreeId)) {
|
||||
continue
|
||||
}
|
||||
const placeholder: Worktree = {
|
||||
id: worktreeId,
|
||||
repoId: parsed.repoId,
|
||||
hostId,
|
||||
displayName: getPathDisplayName(parsed.worktreePath, parsed.repoId),
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
linkedGitLabMR: null,
|
||||
linkedGitLabIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
path: parsed.worktreePath,
|
||||
head: '',
|
||||
branch: '',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
nextWorktreesByRepo =
|
||||
nextWorktreesByRepo === worktreesByRepo ? { ...worktreesByRepo } : nextWorktreesByRepo
|
||||
nextWorktreesByRepo[parsed.repoId] = [...current, placeholder]
|
||||
}
|
||||
return { repos: nextRepos, worktreesByRepo: nextWorktreesByRepo }
|
||||
}
|
||||
|
||||
let terminalTabOwnerCacheSource: Record<string, TerminalTab[]> | null = null
|
||||
let terminalTabOwnerCache = new Map<string, string>()
|
||||
|
||||
|
|
@ -415,6 +507,7 @@ export type TerminalSlice = {
|
|||
pendingIssueCommandSplitByTabId: Record<string, { command: string; env?: Record<string, string> }>
|
||||
tabBarOrderByWorktree: Record<string, string[]>
|
||||
workspaceSessionReady: boolean
|
||||
restoredRuntimeHostIdByWorkspaceSessionKey: Record<string, ExecutionHostId>
|
||||
defaultTerminalTabsAppliedByWorktreeId: Record<string, true>
|
||||
markDefaultTerminalTabsApplied: (worktreeId: string) => void
|
||||
/** True only after hydrateWorkspaceSession ran from a real load of
|
||||
|
|
@ -618,10 +711,17 @@ export type TerminalSlice = {
|
|||
setDeferredSshReconnectTargets: (targetIds: string[]) => void
|
||||
removeDeferredSshReconnectTarget: (targetId: string) => void
|
||||
removeDeferredSshSessionId: (tabId: string) => void
|
||||
hydrateWorkspaceSession: (session: WorkspaceSessionState) => void
|
||||
hydrateWorkspaceSession: (
|
||||
session: WorkspaceSessionState,
|
||||
options?: HydrateWorkspaceSessionOptions
|
||||
) => void
|
||||
reconnectPersistedTerminals: (signal?: AbortSignal) => Promise<void>
|
||||
}
|
||||
|
||||
export type HydrateWorkspaceSessionOptions = {
|
||||
runtimeHostIdByWorkspaceSessionKey?: Record<string, ExecutionHostId>
|
||||
} & WorkspaceSessionHydrationOptions
|
||||
|
||||
export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice> = (set, get) => ({
|
||||
tabsByWorktree: {},
|
||||
activeTabId: null,
|
||||
|
|
@ -645,6 +745,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
nativeChatLaunchPromptByTabId: {},
|
||||
tabBarOrderByWorktree: {},
|
||||
workspaceSessionReady: false,
|
||||
restoredRuntimeHostIdByWorkspaceSessionKey: {},
|
||||
defaultTerminalTabsAppliedByWorktreeId: {},
|
||||
markDefaultTerminalTabsApplied: (worktreeId) =>
|
||||
set((s) => {
|
||||
|
|
@ -2741,16 +2842,21 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
return data
|
||||
},
|
||||
|
||||
hydrateWorkspaceSession: (session) => {
|
||||
hydrateWorkspaceSession: (session, options) => {
|
||||
set((s) => {
|
||||
const runtimeSessionPlaceholders = buildRuntimeSessionPlaceholders({
|
||||
repos: s.repos,
|
||||
runtimeHostIdByWorkspaceSessionKey: options?.runtimeHostIdByWorkspaceSessionKey ?? {},
|
||||
worktreesByRepo: s.worktreesByRepo
|
||||
})
|
||||
const validWorktreeIds = new Set(
|
||||
Object.values(s.worktreesByRepo)
|
||||
Object.values(runtimeSessionPlaceholders.worktreesByRepo)
|
||||
.flat()
|
||||
.map((worktree) => worktree.id)
|
||||
)
|
||||
const knownRepoIds = new Set(s.repos.map((r) => r.id))
|
||||
const knownRepoIds = new Set(runtimeSessionPlaceholders.repos.map((r) => r.id))
|
||||
const repoIdsWithLoadedWorktrees = new Set(
|
||||
Object.entries(s.worktreesByRepo)
|
||||
Object.entries(runtimeSessionPlaceholders.worktreesByRepo)
|
||||
.filter(([, worktrees]) => worktrees.length > 0)
|
||||
.map(([repoId]) => repoId)
|
||||
)
|
||||
|
|
@ -2766,6 +2872,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
for (const workspace of s.folderWorkspaces) {
|
||||
validWorktreeIds.add(folderWorkspaceKey(workspace.id))
|
||||
}
|
||||
addAdditionalValidWorkspaceKeys(validWorktreeIds, options)
|
||||
for (const worktreeId of Object.keys(session.tabsByWorktree)) {
|
||||
const parsedWorkspaceKey = parseWorkspaceKey(worktreeId)
|
||||
if (parsedWorkspaceKey?.type === 'folder') {
|
||||
|
|
@ -2841,9 +2948,10 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
)
|
||||
const fallbackActiveWorktreeId =
|
||||
!session.activeWorktreeId && session.activeRepoId && knownRepoIds.has(session.activeRepoId)
|
||||
? (s.worktreesByRepo[session.activeRepoId]?.find((worktree) => worktree.isMainWorktree)
|
||||
?.id ??
|
||||
s.worktreesByRepo[session.activeRepoId]?.[0]?.id ??
|
||||
? (runtimeSessionPlaceholders.worktreesByRepo[session.activeRepoId]?.find(
|
||||
(worktree) => worktree.isMainWorktree
|
||||
)?.id ??
|
||||
runtimeSessionPlaceholders.worktreesByRepo[session.activeRepoId]?.[0]?.id ??
|
||||
null)
|
||||
: null
|
||||
const activeWorktreeId = (() => {
|
||||
|
|
@ -2866,7 +2974,8 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
const activeTabId =
|
||||
session.activeTabId && validTabIds.has(session.activeTabId) ? session.activeTabId : null
|
||||
const activeRepoId =
|
||||
session.activeRepoId && s.repos.some((repo) => repo.id === session.activeRepoId)
|
||||
session.activeRepoId &&
|
||||
runtimeSessionPlaceholders.repos.some((repo) => repo.id === session.activeRepoId)
|
||||
? session.activeRepoId
|
||||
: null
|
||||
|
||||
|
|
@ -2908,10 +3017,12 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
// createOrAttach RPC, triggering reattach instead of a fresh spawn.
|
||||
const pendingReconnectPtyIdByTabId: Record<string, string> = {}
|
||||
for (const worktreeId of pendingReconnectWorktreeIds) {
|
||||
const worktree = Object.values(s.worktreesByRepo)
|
||||
const worktree = Object.values(runtimeSessionPlaceholders.worktreesByRepo)
|
||||
.flat()
|
||||
.find((entry) => entry.id === worktreeId)
|
||||
const repo = worktree ? s.repos.find((entry) => entry.id === worktree.repoId) : null
|
||||
const repo = worktree
|
||||
? runtimeSessionPlaceholders.repos.find((entry) => entry.id === worktree.repoId)
|
||||
: null
|
||||
if (repo?.connectionId) {
|
||||
continue
|
||||
}
|
||||
|
|
@ -2963,8 +3074,10 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
// data once SSH reconnects and fetchWorktrees runs.
|
||||
// Why: only SSH needs placeholders; local metadata should come from the
|
||||
// next successful fetch so the sidebar does not render synthetic entries.
|
||||
const sshRepoIds = new Set(s.repos.filter((r) => r.connectionId).map((r) => r.id))
|
||||
const worktreesByRepo = { ...s.worktreesByRepo }
|
||||
const sshRepoIds = new Set(
|
||||
runtimeSessionPlaceholders.repos.filter((r) => r.connectionId).map((r) => r.id)
|
||||
)
|
||||
const worktreesByRepo = { ...runtimeSessionPlaceholders.worktreesByRepo }
|
||||
for (const worktreeId of Object.keys(tabsByWorktree)) {
|
||||
const repoId = getRepoIdFromWorktreeId(worktreeId)
|
||||
if (!sshRepoIds.has(repoId)) {
|
||||
|
|
@ -3017,6 +3130,9 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
activeWorkspaceKey,
|
||||
activeTabId,
|
||||
activeTabIdByWorktree,
|
||||
restoredRuntimeHostIdByWorkspaceSessionKey:
|
||||
options?.runtimeHostIdByWorkspaceSessionKey ?? {},
|
||||
repos: runtimeSessionPlaceholders.repos,
|
||||
tabsByWorktree,
|
||||
worktreesByRepo,
|
||||
// Why: restore the per-worktree focus-recency map. Pruning of stale
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { makePaneKey } from '../../../../shared/stable-pane-id'
|
|||
import { buildAgentNotificationId } from '../../../../shared/agent-notification-id'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import type { TaskSourceContext } from '../../../../shared/task-source-context'
|
||||
import { getSetupScriptPromptDismissalKey } from '../../lib/setup-script-prompt'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
sendNotesToActiveAgentSession: vi.fn(),
|
||||
|
|
@ -763,6 +764,56 @@ describe('createUISlice hydratePersistedUI', () => {
|
|||
expect(store.getState().rightSidebarExplorerView).toBe('files')
|
||||
})
|
||||
|
||||
it('preserves persisted repo filters until repos are loaded', () => {
|
||||
const store = createUIStore()
|
||||
const remoteDismissalKey = getSetupScriptPromptDismissalKey('remote-repo')
|
||||
|
||||
store.getState().hydratePersistedUI(
|
||||
makePersistedUI({
|
||||
filterRepoIds: ['remote-repo', 12 as never, 'stale-repo'],
|
||||
trustedOrcaHooks: {
|
||||
'remote-repo': { all: { approvedAt: 1 } },
|
||||
'bad-shape': 'yes' as never
|
||||
},
|
||||
setupScriptPromptDismissedRepoIds: [remoteDismissalKey, 'remote-repo', remoteDismissalKey]
|
||||
})
|
||||
)
|
||||
|
||||
expect(store.getState().filterRepoIds).toEqual(['remote-repo', 'stale-repo'])
|
||||
expect(store.getState().trustedOrcaHooks).toEqual({
|
||||
'remote-repo': { all: { approvedAt: 1 } }
|
||||
})
|
||||
expect(store.getState().setupScriptPromptDismissedRepoIds).toEqual([remoteDismissalKey])
|
||||
})
|
||||
|
||||
it('validates persisted repo filters when repos are already loaded', () => {
|
||||
const store = createUIStore()
|
||||
const localDismissalKey = getSetupScriptPromptDismissalKey('local-repo')
|
||||
const staleDismissalKey = getSetupScriptPromptDismissalKey('stale-repo')
|
||||
store.setState({
|
||||
repos: [
|
||||
{ id: 'local-repo', path: '/local', displayName: 'Local', badgeColor: '#000', addedAt: 1 }
|
||||
]
|
||||
} as Partial<AppState>)
|
||||
|
||||
store.getState().hydratePersistedUI(
|
||||
makePersistedUI({
|
||||
filterRepoIds: ['local-repo', 'stale-repo'],
|
||||
trustedOrcaHooks: {
|
||||
'local-repo': { all: { approvedAt: 1 } },
|
||||
'stale-repo': { all: { approvedAt: 2 } }
|
||||
},
|
||||
setupScriptPromptDismissedRepoIds: [localDismissalKey, staleDismissalKey]
|
||||
})
|
||||
)
|
||||
|
||||
expect(store.getState().filterRepoIds).toEqual(['local-repo'])
|
||||
expect(store.getState().trustedOrcaHooks).toEqual({
|
||||
'local-repo': { all: { approvedAt: 1 } }
|
||||
})
|
||||
expect(store.getState().setupScriptPromptDismissedRepoIds).toEqual([localDismissalKey])
|
||||
})
|
||||
|
||||
it('hydrates legacy persisted search tab as Explorer search', () => {
|
||||
const store = createUIStore()
|
||||
|
||||
|
|
|
|||
|
|
@ -89,7 +89,8 @@ import type { OrcaHookScriptKind } from '../../lib/orca-hook-trust'
|
|||
import type { SettingsNavTarget } from '@/lib/settings-navigation-types'
|
||||
import {
|
||||
filterSetupScriptPromptDismissalsToValidRepos,
|
||||
getSetupScriptPromptDismissalKey
|
||||
getSetupScriptPromptDismissalKey,
|
||||
sanitizeSetupScriptPromptDismissals
|
||||
} from '../../lib/setup-script-prompt'
|
||||
import { DEFAULT_PET_ID, isBundledPetId } from '../../components/pet/pet-models'
|
||||
import { revokeCustomPetBlobUrl } from '../../components/pet/pet-blob-cache'
|
||||
|
|
@ -349,12 +350,38 @@ function collectAcknowledgedAgentNotificationId({
|
|||
}
|
||||
}
|
||||
|
||||
function filterTrustedOrcaHooksToValidRepos(
|
||||
trust: PersistedTrustedOrcaHooks,
|
||||
validRepoIds: Set<string>
|
||||
): PersistedTrustedOrcaHooks {
|
||||
function isPlainPersistedRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function sanitizePersistedRepoIds(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
return value.filter((repoId): repoId is string => typeof repoId === 'string')
|
||||
}
|
||||
|
||||
function sanitizeTrustedOrcaHooks(trust: unknown): PersistedTrustedOrcaHooks {
|
||||
if (!isPlainPersistedRecord(trust)) {
|
||||
return {}
|
||||
}
|
||||
const next: PersistedTrustedOrcaHooks = {}
|
||||
for (const [repoId, entry] of Object.entries(trust)) {
|
||||
if (!isSafePersistedRecordKey(repoId) || !isPlainPersistedRecord(entry)) {
|
||||
continue
|
||||
}
|
||||
next[repoId] = entry as PersistedTrustedOrcaHooks[string]
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function filterTrustedOrcaHooksToValidRepos(
|
||||
trust: unknown,
|
||||
validRepoIds: Set<string>
|
||||
): PersistedTrustedOrcaHooks {
|
||||
const sanitized = sanitizeTrustedOrcaHooks(trust)
|
||||
const next: PersistedTrustedOrcaHooks = {}
|
||||
for (const [repoId, entry] of Object.entries(sanitized)) {
|
||||
if (validRepoIds.has(repoId)) {
|
||||
next[repoId] = entry
|
||||
}
|
||||
|
|
@ -362,6 +389,17 @@ function filterTrustedOrcaHooksToValidRepos(
|
|||
return next
|
||||
}
|
||||
|
||||
function hydrateTrustedOrcaHooks(
|
||||
trust: unknown,
|
||||
validRepoIds: Set<string>
|
||||
): PersistedTrustedOrcaHooks {
|
||||
const sanitized = sanitizeTrustedOrcaHooks(trust)
|
||||
if (validRepoIds.size === 0) {
|
||||
return sanitized
|
||||
}
|
||||
return filterTrustedOrcaHooksToValidRepos(sanitized, validRepoIds)
|
||||
}
|
||||
|
||||
function isSafePersistedRecordKey(key: string): boolean {
|
||||
return key !== '__proto__' && key !== 'constructor' && key !== 'prototype'
|
||||
}
|
||||
|
|
@ -2192,6 +2230,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
|
|||
hydratePersistedUI: (ui) =>
|
||||
set((s) => {
|
||||
const validRepoIds = new Set(s.repos.map((repo) => repo.id))
|
||||
const persistedFilterRepoIds = sanitizePersistedRepoIds(ui.filterRepoIds)
|
||||
// Why: persisted UI from pre-rename builds used sidekick* keys. Read
|
||||
// those only as fallbacks so new pet* writes win immediately after upgrade.
|
||||
const customPets = Array.isArray(ui.customPets)
|
||||
|
|
@ -2283,7 +2322,12 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
|
|||
hideDefaultBranchWorkspace: ui.hideDefaultBranchWorkspace ?? false,
|
||||
hideAutomationGeneratedWorkspaces: ui.hideAutomationGeneratedWorkspaces === true,
|
||||
showDotfilesByWorktree: sanitizeShowDotfilesByWorktree(ui.showDotfilesByWorktree),
|
||||
filterRepoIds: (ui.filterRepoIds ?? []).filter((repoId) => validRepoIds.has(repoId)),
|
||||
// Why: startup hydrates UI before repo catalogs now. With no catalog
|
||||
// loaded yet, defer repo-filter validation to the all-host repo refresh.
|
||||
filterRepoIds:
|
||||
validRepoIds.size === 0
|
||||
? persistedFilterRepoIds
|
||||
: persistedFilterRepoIds.filter((repoId) => validRepoIds.has(repoId)),
|
||||
collapsedGroups: new Set(ui.collapsedGroups ?? []),
|
||||
uiZoomLevel: ui.uiZoomLevel ?? 0,
|
||||
editorFontZoomLevel: ui.editorFontZoomLevel ?? 0,
|
||||
|
|
@ -2332,14 +2376,14 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
|
|||
typeof ui.contextualToursAutoEligible === 'boolean'
|
||||
? ui.contextualToursAutoEligible
|
||||
: null,
|
||||
trustedOrcaHooks: filterTrustedOrcaHooksToValidRepos(
|
||||
ui.trustedOrcaHooks ?? {},
|
||||
validRepoIds
|
||||
),
|
||||
setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos(
|
||||
ui.setupScriptPromptDismissedRepoIds,
|
||||
validRepoIds
|
||||
),
|
||||
trustedOrcaHooks: hydrateTrustedOrcaHooks(ui.trustedOrcaHooks, validRepoIds),
|
||||
setupScriptPromptDismissedRepoIds:
|
||||
validRepoIds.size === 0
|
||||
? sanitizeSetupScriptPromptDismissals(ui.setupScriptPromptDismissedRepoIds)
|
||||
: filterSetupScriptPromptDismissalsToValidRepos(
|
||||
ui.setupScriptPromptDismissedRepoIds,
|
||||
validRepoIds
|
||||
),
|
||||
setupGuideSidebarDismissed: ui.setupGuideSidebarDismissed === true,
|
||||
setupGuideBrowserMilestoneMigrated: ui.setupGuideBrowserMilestoneMigrated === true,
|
||||
setupGuideBrowserMilestoneLegacyComplete:
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ export type WorktreeSlice = {
|
|||
hasHydratedWorktreePurge: boolean
|
||||
fetchDetectedWorktrees: (repoId: string) => Promise<DetectedWorktreeListResult | null>
|
||||
fetchWorktrees: (repoId: string, options?: { requireAuthoritative?: boolean }) => Promise<boolean>
|
||||
fetchAllWorktrees: () => Promise<void>
|
||||
fetchAllWorktrees: (options?: { hydrationPurge?: 'allow' | 'defer' }) => Promise<void>
|
||||
fetchWorktreeLineage: () => Promise<void>
|
||||
updateWorktreeLineage: (
|
||||
worktreeId: string,
|
||||
|
|
|
|||
|
|
@ -5493,6 +5493,8 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => {
|
|||
const store = createTestStore()
|
||||
const wtA = makeWorktree({ id: 'repoA::/a/wt1', repoId: 'repoA', path: '/a/wt1' })
|
||||
const wtB = makeWorktree({ id: 'repoB::/b/wt1', repoId: 'repoB', path: '/b/wt1' })
|
||||
const folderWorkspace = makeFolderWorkspace({ id: 'folder-keep' })
|
||||
const folderKey = folderWorkspaceKey(folderWorkspace.id)
|
||||
|
||||
mockApi.worktrees.list.mockImplementation(async ({ repoId }: { repoId: string }) =>
|
||||
repoId === 'repoA' ? [wtA] : [wtB]
|
||||
|
|
@ -5500,15 +5502,18 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => {
|
|||
|
||||
store.setState({
|
||||
repos: [repoA, repoB],
|
||||
folderWorkspaces: [folderWorkspace],
|
||||
tabsByWorktree: {
|
||||
'repoA::/a/wt1': [{ id: 'tab-A', worktreeId: 'repoA::/a/wt1' }],
|
||||
'repoA::/a/zombie': [{ id: 'tab-zombie', worktreeId: 'repoA::/a/zombie' }],
|
||||
'repoB::/b/wt1': [{ id: 'tab-B', worktreeId: 'repoB::/b/wt1' }]
|
||||
'repoB::/b/wt1': [{ id: 'tab-B', worktreeId: 'repoB::/b/wt1' }],
|
||||
[folderKey]: [{ id: 'tab-folder', worktreeId: folderKey }]
|
||||
},
|
||||
gitIgnoredPathsByWorktree: {
|
||||
'repoA::/a/wt1': ['dist/'],
|
||||
'repoA::/a/zombie': ['coverage/'],
|
||||
'repoB::/b/wt1': ['build/']
|
||||
'repoB::/b/wt1': ['build/'],
|
||||
[folderKey]: ['tmp/']
|
||||
}
|
||||
} as unknown as Partial<AppState>)
|
||||
|
||||
|
|
@ -5518,11 +5523,13 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => {
|
|||
expect(mockApi.worktrees.list).toHaveBeenCalledTimes(2)
|
||||
expect(store.getState().tabsByWorktree).toEqual({
|
||||
'repoA::/a/wt1': [{ id: 'tab-A', worktreeId: 'repoA::/a/wt1' }],
|
||||
'repoB::/b/wt1': [{ id: 'tab-B', worktreeId: 'repoB::/b/wt1' }]
|
||||
'repoB::/b/wt1': [{ id: 'tab-B', worktreeId: 'repoB::/b/wt1' }],
|
||||
[folderKey]: [{ id: 'tab-folder', worktreeId: folderKey }]
|
||||
})
|
||||
expect(store.getState().gitIgnoredPathsByWorktree).toEqual({
|
||||
'repoA::/a/wt1': ['dist/'],
|
||||
'repoB::/b/wt1': ['build/']
|
||||
'repoB::/b/wt1': ['build/'],
|
||||
[folderKey]: ['tmp/']
|
||||
})
|
||||
|
||||
// Second call must not re-run the purge even if new stale ids appear.
|
||||
|
|
@ -5539,6 +5546,79 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => {
|
|||
expect(store.getState().tabsByWorktree['repoA::/a/new-zombie']).toBeDefined()
|
||||
})
|
||||
|
||||
it('can defer the first successful purge during local-only startup refresh', async () => {
|
||||
const store = createTestStore()
|
||||
const wtA = makeWorktree({ id: 'repoA::/a/wt1', repoId: 'repoA', path: '/a/wt1' })
|
||||
const wtB = makeWorktree({ id: 'repoB::/b/wt1', repoId: 'repoB', path: '/b/wt1' })
|
||||
|
||||
mockApi.worktrees.list.mockImplementation(async ({ repoId }: { repoId: string }) =>
|
||||
repoId === 'repoA' ? [wtA] : [wtB]
|
||||
)
|
||||
|
||||
store.setState({
|
||||
repos: [repoA, repoB],
|
||||
tabsByWorktree: {
|
||||
'repoA::/a/wt1': [{ id: 'tab-A', worktreeId: 'repoA::/a/wt1' }],
|
||||
'repoA::/a/zombie': [{ id: 'tab-zombie', worktreeId: 'repoA::/a/zombie' }],
|
||||
'repoB::/b/wt1': [{ id: 'tab-B', worktreeId: 'repoB::/b/wt1' }]
|
||||
}
|
||||
} as unknown as Partial<AppState>)
|
||||
|
||||
await store.getState().fetchAllWorktrees({ hydrationPurge: 'defer' })
|
||||
|
||||
expect(store.getState().hasHydratedWorktreePurge).toBe(false)
|
||||
expect(store.getState().tabsByWorktree['repoA::/a/zombie']).toBeDefined()
|
||||
|
||||
await store.getState().fetchAllWorktrees()
|
||||
|
||||
expect(store.getState().hasHydratedWorktreePurge).toBe(true)
|
||||
expect(store.getState().tabsByWorktree).toEqual({
|
||||
'repoA::/a/wt1': [{ id: 'tab-A', worktreeId: 'repoA::/a/wt1' }],
|
||||
'repoB::/b/wt1': [{ id: 'tab-B', worktreeId: 'repoB::/b/wt1' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('does not consume the one-shot purge before clean workspace session hydration', async () => {
|
||||
const store = createTestStore()
|
||||
const wtA = makeWorktree({ id: 'repoA::/a/wt1', repoId: 'repoA', path: '/a/wt1' })
|
||||
const wtB = makeWorktree({ id: 'repoB::/b/wt1', repoId: 'repoB', path: '/b/wt1' })
|
||||
|
||||
mockApi.worktrees.list.mockImplementation(async ({ repoId }: { repoId: string }) =>
|
||||
repoId === 'repoA' ? [wtA] : [wtB]
|
||||
)
|
||||
|
||||
store.setState({
|
||||
workspaceSessionReady: false,
|
||||
hydrationSucceeded: false,
|
||||
repos: [repoA, repoB],
|
||||
tabsByWorktree: {
|
||||
'repoA::/a/wt1': [{ id: 'tab-A', worktreeId: 'repoA::/a/wt1' }],
|
||||
'repoA::/a/zombie': [{ id: 'tab-zombie', worktreeId: 'repoA::/a/zombie' }],
|
||||
'repoB::/b/wt1': [{ id: 'tab-B', worktreeId: 'repoB::/b/wt1' }]
|
||||
}
|
||||
} as unknown as Partial<AppState>)
|
||||
|
||||
await store.getState().fetchAllWorktrees()
|
||||
|
||||
expect(store.getState().hasHydratedWorktreePurge).toBe(false)
|
||||
expect(store.getState().tabsByWorktree['repoA::/a/zombie']).toBeDefined()
|
||||
|
||||
store.setState({ workspaceSessionReady: true } as Partial<AppState>)
|
||||
await store.getState().fetchAllWorktrees()
|
||||
|
||||
expect(store.getState().hasHydratedWorktreePurge).toBe(false)
|
||||
expect(store.getState().tabsByWorktree['repoA::/a/zombie']).toBeDefined()
|
||||
|
||||
store.setState({ hydrationSucceeded: true } as Partial<AppState>)
|
||||
await store.getState().fetchAllWorktrees()
|
||||
|
||||
expect(store.getState().hasHydratedWorktreePurge).toBe(true)
|
||||
expect(store.getState().tabsByWorktree).toEqual({
|
||||
'repoA::/a/wt1': [{ id: 'tab-A', worktreeId: 'repoA::/a/wt1' }],
|
||||
'repoB::/b/wt1': [{ id: 'tab-B', worktreeId: 'repoB::/b/wt1' }]
|
||||
})
|
||||
})
|
||||
|
||||
// Why: multi-host regression — once hydration has fired, a mid-session
|
||||
// fetchAllWorktrees (e.g. triggered by switching focus) must NEVER purge
|
||||
// terminal state, even if a host transiently reports zero worktrees. The
|
||||
|
|
|
|||
|
|
@ -2295,7 +2295,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
|||
}
|
||||
},
|
||||
|
||||
fetchAllWorktrees: async () => {
|
||||
fetchAllWorktrees: async (options) => {
|
||||
const { repos } = get()
|
||||
|
||||
// Why: once the one-shot hydration-time purge has fired, subsequent
|
||||
|
|
@ -2437,10 +2437,31 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
|||
// Defer; try again on the next fetchAllWorktrees call.
|
||||
return
|
||||
}
|
||||
if (
|
||||
options?.hydrationPurge === 'defer' ||
|
||||
get().workspaceSessionReady === false ||
|
||||
get().hydrationSucceeded === false
|
||||
) {
|
||||
// Why: startup first refreshes only local repos so the app can paint
|
||||
// before remote runtime timeouts. Keep the one-shot purge available for
|
||||
// the later all-host refresh, after a clean session hydrate and when
|
||||
// remote worktree ids are known too.
|
||||
return
|
||||
}
|
||||
const validIds = new Set<string>()
|
||||
// Why: floating is persisted renderer state, but not a repo worktree that
|
||||
// authoritative runtime scans can return.
|
||||
validIds.add(FLOATING_TERMINAL_WORKTREE_ID)
|
||||
// Why: folder workspaces persist terminal tabs under `folder:<id>` keys,
|
||||
// but authoritative repo scans can never return those synthetic ids.
|
||||
for (const workspace of get().folderWorkspaces ?? []) {
|
||||
validIds.add(folderWorkspaceKey(workspace.id))
|
||||
}
|
||||
for (const key of Object.keys(get().restoredRuntimeHostIdByWorkspaceSessionKey ?? {})) {
|
||||
if (parseWorkspaceKey(key)?.type === 'folder') {
|
||||
validIds.add(key)
|
||||
}
|
||||
}
|
||||
for (const result of Object.values(get().detectedWorktreesByRepo)) {
|
||||
if (!result.authoritative) {
|
||||
continue
|
||||
|
|
|
|||
Loading…
Reference in New Issue