fix(runtime): bound persisted graph hydration (#11832)
This commit is contained in:
parent
377b580bab
commit
33ad64b1c8
|
|
@ -26944,6 +26944,86 @@ describe('OrcaRuntimeService', () => {
|
|||
expect(secondMerge.publicationEpoch.match(/:headless-merge:/g) ?? []).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps the graph ready when a mobile snapshot references a removed folder workspace', () => {
|
||||
const runtime = new OrcaRuntimeService({
|
||||
...store,
|
||||
getFolderWorkspaces: () => []
|
||||
} as never)
|
||||
|
||||
expect(() =>
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [],
|
||||
leaves: [],
|
||||
mobileSessionTabs: [
|
||||
{
|
||||
worktree: 'folder:removed-folder',
|
||||
publicationEpoch: 'stale-folder-publication',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: null,
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabs: []
|
||||
}
|
||||
]
|
||||
})
|
||||
).not.toThrow()
|
||||
expect(runtime.getStatus().graphStatus).toBe('ready')
|
||||
})
|
||||
|
||||
it('scans ordinary persisted sessions once instead of once per graph workspace', () => {
|
||||
const tabsByWorktree = Object.fromEntries(
|
||||
Array.from({ length: 100 }, (_, index) => [
|
||||
`${TEST_REPO_ID}::/tmp/worktree-${index}`,
|
||||
[{ id: `tab-${index}`, ptyId: `${TEST_REPO_ID}::/tmp/worktree-${index}@@pty` }]
|
||||
])
|
||||
)
|
||||
const session = { tabsByWorktree, terminalLayoutsByTabId: {} }
|
||||
const getWorkspaceSession = vi.fn(() => session)
|
||||
const runtime = new OrcaRuntimeService({
|
||||
...store,
|
||||
getWorkspaceSession,
|
||||
getWorkspaceSessionHostIds: () => ['local']
|
||||
} as never)
|
||||
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [],
|
||||
leaves: [],
|
||||
mobileSessionTabs: Object.keys(tabsByWorktree).map((worktree) => ({
|
||||
worktree,
|
||||
publicationEpoch: 'large-profile',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: null,
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabs: []
|
||||
}))
|
||||
})
|
||||
|
||||
expect(getWorkspaceSession).toHaveBeenCalledTimes(1)
|
||||
expect(runtime.getStatus().graphStatus).toBe('ready')
|
||||
})
|
||||
|
||||
it('hydrates runtime-owned candidates from one host-session read', () => {
|
||||
const tabsByWorktree = Object.fromEntries(
|
||||
Array.from({ length: 100 }, (_, index) => [
|
||||
`${TEST_REPO_ID}::/tmp/runtime-worktree-${index}`,
|
||||
[{ id: `runtime-tab-${index}`, ptyId: `serve-runtime-${index}` }]
|
||||
])
|
||||
)
|
||||
const session = { tabsByWorktree, terminalLayoutsByTabId: {} }
|
||||
const getWorkspaceSession = vi.fn(() => session)
|
||||
const runtime = new OrcaRuntimeService({
|
||||
...store,
|
||||
getWorkspaceSession,
|
||||
getWorkspaceSessionHostIds: () => ['local']
|
||||
} as never)
|
||||
|
||||
runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] })
|
||||
|
||||
expect(getWorkspaceSession).toHaveBeenCalledTimes(1)
|
||||
expect(runtime.getStatus().graphStatus).toBe('ready')
|
||||
})
|
||||
|
||||
it('briefly preserves abnormal SSH exits for paired pane recovery', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
|
|
@ -27180,7 +27260,8 @@ describe('OrcaRuntimeService', () => {
|
|||
getWorkspaceSession
|
||||
} as never)
|
||||
|
||||
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
|
||||
runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] })
|
||||
expect(getWorkspaceSession).toHaveBeenCalledTimes(2)
|
||||
|
||||
expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual([
|
||||
expect.objectContaining({
|
||||
|
|
|
|||
|
|
@ -4561,14 +4561,14 @@ export class OrcaRuntimeService {
|
|||
return this.startedAt
|
||||
}
|
||||
|
||||
private getWorkspaceSessionHostIdForWorktree(worktreeId: string): ExecutionHostId {
|
||||
private tryGetWorkspaceSessionHostIdForWorktree(worktreeId: string): ExecutionHostId | null {
|
||||
const scope = parseWorkspaceKey(worktreeId)
|
||||
if (scope?.type === 'folder') {
|
||||
const workspace = this.store
|
||||
?.getFolderWorkspaces?.()
|
||||
.find((entry) => entry.id === scope.folderWorkspaceId)
|
||||
if (!workspace) {
|
||||
throw new Error('folder_workspace_not_found')
|
||||
return null
|
||||
}
|
||||
const connectionId = this.resolveFolderWorkspaceConnectionId(workspace)
|
||||
return connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID
|
||||
|
|
@ -4578,11 +4578,17 @@ export class OrcaRuntimeService {
|
|||
return repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID
|
||||
}
|
||||
|
||||
private getWorkspaceSessionHostIdForWorktree(worktreeId: string): ExecutionHostId {
|
||||
const hostId = this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId)
|
||||
if (!hostId) {
|
||||
throw new Error('folder_workspace_not_found')
|
||||
}
|
||||
return hostId
|
||||
}
|
||||
|
||||
private getWorkspaceSessionForWorktree(worktreeId: string): WorkspaceSessionState | null {
|
||||
return (
|
||||
this.store?.getWorkspaceSession?.(this.getWorkspaceSessionHostIdForWorktree(worktreeId)) ??
|
||||
null
|
||||
)
|
||||
const hostId = this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId)
|
||||
return hostId ? (this.store?.getWorkspaceSession?.(hostId) ?? null) : null
|
||||
}
|
||||
|
||||
private setWorkspaceSessionForWorktree(worktreeId: string, session: WorkspaceSessionState): void {
|
||||
|
|
@ -4611,6 +4617,56 @@ export class OrcaRuntimeService {
|
|||
return worktreeIds
|
||||
}
|
||||
|
||||
private getWorkspaceSessionHydrationTargets(
|
||||
includeAllPersistedWorktrees: boolean
|
||||
): Map<string, WorkspaceSessionState> {
|
||||
const repos = this.store?.getRepos?.() ?? []
|
||||
const repoHostIdByRepoId = new Map(
|
||||
repos.map((repo) => [repo.id, getRepoExecutionHostId(repo)] as const)
|
||||
)
|
||||
const folderHostIdByWorkspaceId = new Map(
|
||||
(this.store?.getFolderWorkspaces?.() ?? []).map((workspace) => {
|
||||
const connectionId = this.resolveFolderWorkspaceConnectionId(workspace)
|
||||
return [
|
||||
workspace.id,
|
||||
connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID
|
||||
] as const
|
||||
})
|
||||
)
|
||||
const hostIds = new Set<ExecutionHostId>(['local'])
|
||||
for (const repo of repos) {
|
||||
hostIds.add(getRepoExecutionHostId(repo))
|
||||
}
|
||||
for (const hostId of this.store?.getWorkspaceSessionHostIds?.() ?? []) {
|
||||
hostIds.add(hostId)
|
||||
}
|
||||
|
||||
const targets = new Map<string, WorkspaceSessionState>()
|
||||
for (const hostId of hostIds) {
|
||||
const session = this.store?.getWorkspaceSession?.(hostId)
|
||||
if (!session) {
|
||||
continue
|
||||
}
|
||||
for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree ?? {})) {
|
||||
const scope = parseWorkspaceKey(worktreeId)
|
||||
const ownerHostId =
|
||||
scope?.type === 'folder'
|
||||
? (folderHostIdByWorkspaceId.get(scope.folderWorkspaceId) ?? null)
|
||||
: (repoHostIdByRepoId.get(
|
||||
getRepoIdFromWorktreeId(scope?.type === 'worktree' ? scope.worktreeId : worktreeId)
|
||||
) ?? LOCAL_EXECUTION_HOST_ID)
|
||||
if (
|
||||
ownerHostId === hostId &&
|
||||
(includeAllPersistedWorktrees ||
|
||||
this.workspaceSessionWorktreeHasRuntimeOwnedPtyCandidate(session, worktreeId, tabs))
|
||||
) {
|
||||
targets.set(worktreeId, session)
|
||||
}
|
||||
}
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
getStatus(): RuntimeStatus {
|
||||
// Why: browser panes need a backend that can create and stream a page. A
|
||||
// desktop renderer provides one via <webview>; a headless serve provides one
|
||||
|
|
@ -5475,6 +5531,8 @@ export class OrcaRuntimeService {
|
|||
force?: boolean
|
||||
allowAttachedWindow?: boolean
|
||||
onlyRuntimeOwnedTerminals?: boolean
|
||||
runtimeOwnedTerminalCandidateKnown?: boolean
|
||||
workspaceSession?: WorkspaceSessionState
|
||||
} = {}
|
||||
): Set<string> {
|
||||
// Why: report which worktrees were reconciled in place so callers don't
|
||||
|
|
@ -5483,9 +5541,11 @@ export class OrcaRuntimeService {
|
|||
if (this.getAvailableAuthoritativeWindow() && options.allowAttachedWindow !== true) {
|
||||
return reconciledWorktreeIds
|
||||
}
|
||||
const session = worktreeId
|
||||
? this.getWorkspaceSessionForWorktree(worktreeId)
|
||||
: this.store?.getWorkspaceSession?.()
|
||||
const session =
|
||||
options.workspaceSession ??
|
||||
(worktreeId
|
||||
? this.getWorkspaceSessionForWorktree(worktreeId)
|
||||
: this.store?.getWorkspaceSession?.())
|
||||
if (!session) {
|
||||
return reconciledWorktreeIds
|
||||
}
|
||||
|
|
@ -5497,7 +5557,14 @@ export class OrcaRuntimeService {
|
|||
if (
|
||||
options.onlyRuntimeOwnedTerminals === true &&
|
||||
!this.offscreenBrowserBackend &&
|
||||
!this.workspaceSessionHasRuntimeOwnedPtyCandidate(session)
|
||||
options.runtimeOwnedTerminalCandidateKnown !== true &&
|
||||
!(worktreeId
|
||||
? this.workspaceSessionWorktreeHasRuntimeOwnedPtyCandidate(
|
||||
session,
|
||||
worktreeId,
|
||||
session.tabsByWorktree[worktreeId] ?? []
|
||||
)
|
||||
: this.workspaceSessionHasRuntimeOwnedPtyCandidate(session))
|
||||
) {
|
||||
return reconciledWorktreeIds
|
||||
}
|
||||
|
|
@ -5542,7 +5609,8 @@ export class OrcaRuntimeService {
|
|||
}
|
||||
const terminalTabs = this.buildHeadlessMobileSessionTerminalTabs(
|
||||
entryWorktreeId,
|
||||
persistedTabs
|
||||
persistedTabs,
|
||||
session
|
||||
).filter(
|
||||
(tab) =>
|
||||
options.onlyRuntimeOwnedTerminals !== true ||
|
||||
|
|
@ -5854,26 +5922,30 @@ export class OrcaRuntimeService {
|
|||
}
|
||||
|
||||
private workspaceSessionHasRuntimeOwnedPtyCandidate(session: WorkspaceSessionState): boolean {
|
||||
for (const tabs of Object.values(session.tabsByWorktree ?? {})) {
|
||||
for (const tab of tabs) {
|
||||
if (this.isServeOrSshOwnedPtyId(tab.ptyId)) {
|
||||
return true
|
||||
}
|
||||
const leafPtyIds = session.terminalLayoutsByTabId?.[tab.id]?.ptyIdsByLeafId
|
||||
if (
|
||||
leafPtyIds &&
|
||||
Object.values(leafPtyIds).some((ptyId) => this.isServeOrSshOwnedPtyId(ptyId))
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
// Why: expiry clears the stale PTY id but retains pane coordinates so paired viewers can ask the HUB for a fresh shell.
|
||||
return Object.entries(session.tabsByWorktree ?? {}).some(([worktreeId, tabs]) =>
|
||||
tabs.some((tab) => this.getRecentExpiredSshLease(worktreeId, tab.id, undefined) !== null)
|
||||
this.workspaceSessionWorktreeHasRuntimeOwnedPtyCandidate(session, worktreeId, tabs)
|
||||
)
|
||||
}
|
||||
|
||||
private workspaceSessionWorktreeHasRuntimeOwnedPtyCandidate(
|
||||
session: WorkspaceSessionState,
|
||||
worktreeId: string,
|
||||
tabs: WorkspaceSessionState['tabsByWorktree'][string]
|
||||
): boolean {
|
||||
return tabs.some((tab) => {
|
||||
if (this.isServeOrSshOwnedPtyId(tab.ptyId)) {
|
||||
return true
|
||||
}
|
||||
const leafPtyIds = session.terminalLayoutsByTabId?.[tab.id]?.ptyIdsByLeafId
|
||||
return (
|
||||
(leafPtyIds &&
|
||||
Object.values(leafPtyIds).some((ptyId) => this.isServeOrSshOwnedPtyId(ptyId))) ||
|
||||
// Why: expiry keeps pane coordinates so paired viewers can request a fresh shell.
|
||||
this.getRecentExpiredSshLease(worktreeId, tab.id, undefined) !== null
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private getRecentExpiredSshLease(
|
||||
worktreeId: string,
|
||||
tabId: string,
|
||||
|
|
@ -6482,12 +6554,9 @@ export class OrcaRuntimeService {
|
|||
|
||||
private buildHeadlessMobileSessionTerminalTabs(
|
||||
worktreeId: string,
|
||||
persistedTabs: readonly TerminalTab[]
|
||||
persistedTabs: readonly TerminalTab[],
|
||||
session: WorkspaceSessionState
|
||||
): RuntimeMobileSessionTerminalTab[] {
|
||||
const session = this.getWorkspaceSessionForWorktree(worktreeId)
|
||||
if (!session) {
|
||||
return []
|
||||
}
|
||||
return [...persistedTabs]
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt)
|
||||
.flatMap((tab, index) => {
|
||||
|
|
@ -6519,7 +6588,13 @@ export class OrcaRuntimeService {
|
|||
...(tab.color != null ? { color: tab.color } : {}),
|
||||
...(tab.isPinned ? { isPinned: true } : {}),
|
||||
...(tab.viewMode ? { viewMode: tab.viewMode } : {}),
|
||||
isActive: this.isPersistedTerminalLeafActive(worktreeId, tab.id, leafId, layout)
|
||||
isActive: this.isPersistedTerminalLeafActive(
|
||||
session,
|
||||
worktreeId,
|
||||
tab.id,
|
||||
leafId,
|
||||
layout
|
||||
)
|
||||
}
|
||||
]
|
||||
})
|
||||
|
|
@ -6708,13 +6783,13 @@ export class OrcaRuntimeService {
|
|||
}
|
||||
|
||||
private isPersistedTerminalLeafActive(
|
||||
session: WorkspaceSessionState,
|
||||
worktreeId: string,
|
||||
tabId: string,
|
||||
leafId: string,
|
||||
layout: TerminalLayoutSnapshot | undefined
|
||||
): boolean {
|
||||
const session = this.getWorkspaceSessionForWorktree(worktreeId)
|
||||
const activeTabId = session?.activeTabIdByWorktree?.[worktreeId] ?? session?.activeTabId
|
||||
const activeTabId = session.activeTabIdByWorktree?.[worktreeId] ?? session.activeTabId
|
||||
return activeTabId === tabId && (!layout?.activeLeafId || layout.activeLeafId === leafId)
|
||||
}
|
||||
|
||||
|
|
@ -28248,15 +28323,23 @@ export class OrcaRuntimeService {
|
|||
missingSnapshotOnly: true,
|
||||
notify: false
|
||||
})
|
||||
const worktreeIdsToHydrate = this.getKnownWorkspaceSessionWorktreeIds()
|
||||
for (const snapshot of snapshots) {
|
||||
worktreeIdsToHydrate.add(snapshot.worktree)
|
||||
// Why: graph sync must scan each persisted host session once, not once per workspace.
|
||||
const worktreeSessionsToHydrate = new Map<string, WorkspaceSessionState | null>(
|
||||
this.getWorkspaceSessionHydrationTargets(Boolean(this.offscreenBrowserBackend))
|
||||
)
|
||||
if (this.offscreenBrowserBackend) {
|
||||
for (const snapshot of snapshots) {
|
||||
if (!worktreeSessionsToHydrate.has(snapshot.worktree)) {
|
||||
worktreeSessionsToHydrate.set(snapshot.worktree, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Why: an empty renderer publication after HUB restart must not hide SSH panes persisted in this HUB's host partition.
|
||||
for (const worktreeId of worktreeIdsToHydrate) {
|
||||
for (const [worktreeId, workspaceSession] of worktreeSessionsToHydrate) {
|
||||
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, {
|
||||
allowAttachedWindow: true,
|
||||
onlyRuntimeOwnedTerminals: true
|
||||
onlyRuntimeOwnedTerminals: true,
|
||||
...(workspaceSession ? { runtimeOwnedTerminalCandidateKnown: true, workspaceSession } : {})
|
||||
})
|
||||
}
|
||||
const nextWorktrees = new Set<string>()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { getReachableRuntimeSessionMirrorTargets } from './runtime-session-mirror-targets'
|
||||
|
||||
const environments = [
|
||||
{
|
||||
id: 'online-env',
|
||||
createdAt: 100,
|
||||
pairingRevision: 101
|
||||
},
|
||||
{
|
||||
id: 'offline-env',
|
||||
createdAt: 200
|
||||
}
|
||||
]
|
||||
|
||||
describe('getReachableRuntimeSessionMirrorTargets', () => {
|
||||
it('subscribes only after a runtime health probe succeeds', () => {
|
||||
expect(
|
||||
getReachableRuntimeSessionMirrorTargets({
|
||||
settings: { activeRuntimeEnvironmentId: 'online-env' },
|
||||
repos: [{ id: 'offline-repo', connectionId: null, executionHostId: 'runtime:offline-env' }],
|
||||
runtimeEnvironments: environments,
|
||||
runtimeStatusByEnvironmentId: new Map([
|
||||
[
|
||||
'online-env',
|
||||
{
|
||||
status: { runtimeId: 'runtime-online' },
|
||||
connectionGeneration: 3
|
||||
}
|
||||
],
|
||||
['offline-env', { status: null, connectionGeneration: 7 }]
|
||||
])
|
||||
})
|
||||
).toEqual([
|
||||
{
|
||||
environmentId: 'online-env',
|
||||
runtimeId: 'runtime-online',
|
||||
connectionGeneration: 3,
|
||||
pairingRevision: 101
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('waits for both the saved environment and its first successful status', () => {
|
||||
expect(
|
||||
getReachableRuntimeSessionMirrorTargets({
|
||||
settings: { activeRuntimeEnvironmentId: 'online-env' },
|
||||
runtimeEnvironments: environments,
|
||||
runtimeStatusByEnvironmentId: new Map()
|
||||
})
|
||||
).toEqual([])
|
||||
|
||||
expect(
|
||||
getReachableRuntimeSessionMirrorTargets({
|
||||
settings: { activeRuntimeEnvironmentId: 'missing-env' },
|
||||
runtimeEnvironments: environments,
|
||||
runtimeStatusByEnvironmentId: new Map([
|
||||
['missing-env', { status: { runtimeId: 'runtime-missing' } }]
|
||||
])
|
||||
})
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('uses creation time for environments paired before pairing revisions existed', () => {
|
||||
expect(
|
||||
getReachableRuntimeSessionMirrorTargets({
|
||||
settings: { activeRuntimeEnvironmentId: 'offline-env' },
|
||||
runtimeEnvironments: environments,
|
||||
runtimeStatusByEnvironmentId: new Map([
|
||||
['offline-env', { status: { runtimeId: 'runtime-recovered' } }]
|
||||
])
|
||||
})
|
||||
).toEqual([
|
||||
{
|
||||
environmentId: 'offline-env',
|
||||
runtimeId: 'runtime-recovered',
|
||||
connectionGeneration: 0,
|
||||
pairingRevision: 200
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import type { WorktreeRuntimeOwnerState } from './worktree-runtime-owner-state'
|
||||
import { getRuntimeSessionMirrorEnvironmentIds } from './runtime-session-mirror-owners'
|
||||
|
||||
type RuntimeMirrorStatus = {
|
||||
status: { runtimeId: string } | null
|
||||
connectionGeneration?: number
|
||||
}
|
||||
|
||||
type RuntimeMirrorEnvironment = {
|
||||
id: string
|
||||
createdAt: number
|
||||
pairingRevision?: number
|
||||
}
|
||||
|
||||
export type RuntimeSessionMirrorTarget = {
|
||||
environmentId: string
|
||||
runtimeId: string
|
||||
connectionGeneration: number
|
||||
pairingRevision: number
|
||||
}
|
||||
|
||||
export type RuntimeSessionMirrorTargetState = Omit<
|
||||
WorktreeRuntimeOwnerState,
|
||||
'runtimeEnvironments'
|
||||
> & {
|
||||
runtimeEnvironments?: readonly RuntimeMirrorEnvironment[]
|
||||
runtimeStatusByEnvironmentId?: ReadonlyMap<string, RuntimeMirrorStatus>
|
||||
}
|
||||
|
||||
export function getReachableRuntimeSessionMirrorTargets(
|
||||
state: RuntimeSessionMirrorTargetState
|
||||
): RuntimeSessionMirrorTarget[] {
|
||||
const environmentById = new Map(
|
||||
(state.runtimeEnvironments ?? []).map((environment) => [environment.id, environment])
|
||||
)
|
||||
const targets: RuntimeSessionMirrorTarget[] = []
|
||||
for (const environmentId of getRuntimeSessionMirrorEnvironmentIds(state)) {
|
||||
const status = state.runtimeStatusByEnvironmentId?.get(environmentId)
|
||||
if (!status?.status) {
|
||||
continue
|
||||
}
|
||||
const environment = environmentById.get(environmentId)
|
||||
if (!environment) {
|
||||
continue
|
||||
}
|
||||
targets.push({
|
||||
environmentId,
|
||||
runtimeId: status.status.runtimeId,
|
||||
connectionGeneration: status.connectionGeneration ?? 0,
|
||||
pairingRevision: environment.pairingRevision ?? environment.createdAt
|
||||
})
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
|
@ -428,6 +428,29 @@ describe('getRuntimeMobileSessionSyncKey', () => {
|
|||
expect(canSkipRuntimeMobileSessionSyncKeyBuild(after, before)).toBe(false)
|
||||
})
|
||||
|
||||
it('changes and does not skip when a folder workspace is removed', () => {
|
||||
const sharedOverrides = makeSharedOverrides()
|
||||
const folderWorkspace = {
|
||||
id: 'folder-1'
|
||||
} as AppState['folderWorkspaces'][number]
|
||||
const before = makeState({
|
||||
...sharedOverrides,
|
||||
folderWorkspaces: [folderWorkspace]
|
||||
})
|
||||
const after = makeState({
|
||||
...sharedOverrides,
|
||||
folderWorkspaces: []
|
||||
})
|
||||
|
||||
expect(canSkipRuntimeMobileSessionSyncKeyBuild(after, before)).toBe(false)
|
||||
expect(
|
||||
runtimeMobileSessionSyncKeysEqual(
|
||||
getRuntimeMobileSessionSyncKey(before),
|
||||
getRuntimeMobileSessionSyncKey(after)
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('changes when explicit agent status epoch changes', () => {
|
||||
const sharedOverrides = makeSharedOverrides()
|
||||
const before = getRuntimeMobileSessionSyncKey(
|
||||
|
|
@ -596,6 +619,54 @@ describe('getRuntimeMobileSessionSyncKey', () => {
|
|||
})
|
||||
|
||||
describe('buildMobileSessionTabSnapshots', () => {
|
||||
it('does not publish state for a removed folder workspace', () => {
|
||||
const staleFolderKey = 'folder:removed-folder'
|
||||
const state = makeState({
|
||||
folderWorkspaces: [],
|
||||
tabsByWorktree: {
|
||||
[staleFolderKey]: [{ id: 'term-1', title: 'Terminal 1' }]
|
||||
} as unknown as AppState['tabsByWorktree']
|
||||
})
|
||||
|
||||
expect(buildMobileSessionTabSnapshots(state)).toEqual([])
|
||||
})
|
||||
|
||||
it('publishes state for a live folder workspace', () => {
|
||||
const folderWorkspaceId = 'live-folder'
|
||||
const folderKey = `folder:${folderWorkspaceId}`
|
||||
const state = makeState({
|
||||
folderWorkspaces: [{ id: folderWorkspaceId } as AppState['folderWorkspaces'][number]],
|
||||
tabsByWorktree: {
|
||||
[folderKey]: [{ id: 'term-1', title: 'Terminal 1' }]
|
||||
} as unknown as AppState['tabsByWorktree']
|
||||
})
|
||||
|
||||
expect(buildMobileSessionTabSnapshots(state)).toEqual([
|
||||
expect.objectContaining({ worktree: folderKey })
|
||||
])
|
||||
})
|
||||
|
||||
it('evicts a removed folder workspace from the snapshot cache', () => {
|
||||
const folderWorkspaceId = 'cache-eviction-folder'
|
||||
const folderKey = `folder:${folderWorkspaceId}`
|
||||
const liveState = makeState({
|
||||
folderWorkspaces: [{ id: folderWorkspaceId } as AppState['folderWorkspaces'][number]],
|
||||
tabsByWorktree: {
|
||||
[folderKey]: [{ id: 'term-1', title: 'Terminal 1' }]
|
||||
} as unknown as AppState['tabsByWorktree']
|
||||
})
|
||||
const removedState = makeState({
|
||||
folderWorkspaces: [],
|
||||
tabsByWorktree: liveState.tabsByWorktree
|
||||
})
|
||||
|
||||
const initial = buildMobileSessionTabSnapshots(liveState)[0]!
|
||||
expect(buildMobileSessionTabSnapshots(removedState)).toEqual([])
|
||||
const restored = buildMobileSessionTabSnapshots(liveState)[0]!
|
||||
|
||||
expect(restored.snapshotVersion).toBeGreaterThan(initial.snapshotVersion)
|
||||
})
|
||||
|
||||
it('publishes browser and editor color + pin state from unified tabs', () => {
|
||||
const fileId = '/repo/README.md'
|
||||
const state = makeState({
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import type {
|
|||
import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id'
|
||||
import { isWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id'
|
||||
import { isClaudeManagementTitle } from '../../../shared/agent-detection'
|
||||
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import type {
|
||||
Tab,
|
||||
TabGroup,
|
||||
|
|
@ -247,6 +248,7 @@ export type RuntimeMobileSessionSyncKey = {
|
|||
terminalLayoutsByTabId: AppState['terminalLayoutsByTabId']
|
||||
runtimePaneTitlesByTabId: AppState['runtimePaneTitlesByTabId']
|
||||
nativeChatLaunchDraftByTabId: AppState['nativeChatLaunchDraftByTabId']
|
||||
folderWorkspaces: AppState['folderWorkspaces']
|
||||
groupsByWorktree: AppState['groupsByWorktree']
|
||||
activeGroupIdByWorktree: AppState['activeGroupIdByWorktree']
|
||||
layoutByWorktree: AppState['layoutByWorktree']
|
||||
|
|
@ -303,6 +305,7 @@ export function canSkipRuntimeMobileSessionSyncKeyBuild(
|
|||
state.terminalLayoutsByTabId === previousState.terminalLayoutsByTabId &&
|
||||
state.runtimePaneTitlesByTabId === previousState.runtimePaneTitlesByTabId &&
|
||||
state.nativeChatLaunchDraftByTabId === previousState.nativeChatLaunchDraftByTabId &&
|
||||
state.folderWorkspaces === previousState.folderWorkspaces &&
|
||||
state.agentStatusEpoch === previousState.agentStatusEpoch &&
|
||||
state.agentStatusByPaneKey === previousState.agentStatusByPaneKey
|
||||
)
|
||||
|
|
@ -340,6 +343,7 @@ export function getRuntimeMobileSessionSyncKey(
|
|||
terminalLayoutsByTabId: state.terminalLayoutsByTabId,
|
||||
runtimePaneTitlesByTabId: state.runtimePaneTitlesByTabId,
|
||||
nativeChatLaunchDraftByTabId: state.nativeChatLaunchDraftByTabId,
|
||||
folderWorkspaces: state.folderWorkspaces,
|
||||
groupsByWorktree: state.groupsByWorktree,
|
||||
activeGroupIdByWorktree: state.activeGroupIdByWorktree,
|
||||
layoutByWorktree: state.layoutByWorktree ?? EMPTY_LAYOUT_BY_WORKTREE,
|
||||
|
|
@ -585,6 +589,7 @@ export function runtimeMobileSessionSyncKeysEqual(
|
|||
a.terminalLayoutsByTabId === b.terminalLayoutsByTabId &&
|
||||
a.runtimePaneTitlesByTabId === b.runtimePaneTitlesByTabId &&
|
||||
a.nativeChatLaunchDraftByTabId === b.nativeChatLaunchDraftByTabId &&
|
||||
a.folderWorkspaces === b.folderWorkspaces &&
|
||||
a.groupsByWorktree === b.groupsByWorktree &&
|
||||
a.activeGroupIdByWorktree === b.activeGroupIdByWorktree &&
|
||||
a.layoutByWorktree === b.layoutByWorktree &&
|
||||
|
|
@ -757,6 +762,9 @@ export function buildMobileSessionTabSnapshots(
|
|||
// Why: high-frequency title ticks fire mobile sync; cache indexes/hashes by store-slice ref to skip rescanning editor state.
|
||||
const openFileIndexes = getOpenFileIndexes(state.openFiles)
|
||||
const editorDraftVersionByFileId = getEditorDraftVersionByFileId(state.editorDrafts)
|
||||
const liveFolderWorkspaceIds = new Set(
|
||||
(state.folderWorkspaces ?? []).map((workspace) => workspace.id)
|
||||
)
|
||||
const worktreeIds = new Set<string>([
|
||||
...Object.keys(state.tabsByWorktree),
|
||||
...Object.keys(state.groupsByWorktree),
|
||||
|
|
@ -767,6 +775,14 @@ export function buildMobileSessionTabSnapshots(
|
|||
|
||||
const snapshots: RuntimeMobileSessionTabsSnapshot[] = []
|
||||
for (const worktreeId of worktreeIds) {
|
||||
const workspaceScope = parseWorkspaceKey(worktreeId)
|
||||
if (
|
||||
workspaceScope?.type === 'folder' &&
|
||||
!liveFolderWorkspaceIds.has(workspaceScope.folderWorkspaceId)
|
||||
) {
|
||||
mobileSessionSnapshotCacheByWorktree.delete(worktreeId)
|
||||
continue
|
||||
}
|
||||
const activeGroupId = state.activeGroupIdByWorktree[worktreeId] ?? null
|
||||
const terminalTabByIdForWorktree = new Map(
|
||||
(state.tabsByWorktree[worktreeId] ?? []).map((tab) => [tab.id, tab])
|
||||
|
|
|
|||
|
|
@ -33,10 +33,8 @@ import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/sta
|
|||
import { getRemoteRuntimePtyEnvironmentId, toRemoteRuntimePtyId } from './runtime-terminal-stream'
|
||||
import { sanitizeTerminalLayoutPaneTitlesForLabels } from '@/lib/terminal-pane-title-sanitization'
|
||||
import { normalizeTerminalLayoutPtyOwnership } from '@/components/terminal-pane/terminal-layout-pty-ownership'
|
||||
import {
|
||||
getExplicitRuntimeEnvironmentIdForWorktree,
|
||||
getRuntimeSessionMirrorEnvironmentIds
|
||||
} from '@/lib/worktree-runtime-owner'
|
||||
import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { getReachableRuntimeSessionMirrorTargets } from '@/lib/runtime-session-mirror-targets'
|
||||
import {
|
||||
createWebRuntimeSessionTerminal,
|
||||
HOST_TERMINAL_SURFACE_SEPARATOR,
|
||||
|
|
@ -2694,17 +2692,11 @@ export function applyWebSessionTabsStorePatch(
|
|||
export function useWebSessionTabsSync(): void {
|
||||
const activeWorktreeId = useAppStore((state) => state.activeWorktreeId)
|
||||
const runtimeSessionMirrorEnvironmentKey = useAppStore((state) =>
|
||||
getRuntimeSessionMirrorEnvironmentIds(state)
|
||||
.map((environmentId) => {
|
||||
const status = state.runtimeStatusByEnvironmentId.get(environmentId)
|
||||
const environment = state.runtimeEnvironments.find(
|
||||
(candidate) => candidate.id === environmentId
|
||||
)
|
||||
const pairingRevision = environment
|
||||
? (environment.pairingRevision ?? environment.createdAt)
|
||||
: ''
|
||||
return `${environmentId}\u0001${status?.status?.runtimeId ?? ''}\u0001${status?.connectionGeneration ?? 0}\u0001${pairingRevision}`
|
||||
})
|
||||
getReachableRuntimeSessionMirrorTargets(state)
|
||||
.map(
|
||||
({ environmentId, runtimeId, connectionGeneration, pairingRevision }) =>
|
||||
`${environmentId}\u0001${runtimeId}\u0001${connectionGeneration}\u0001${pairingRevision}`
|
||||
)
|
||||
.join('\u0000')
|
||||
)
|
||||
const activeWorktreeRuntimeEnvironmentId = useAppStore((state) =>
|
||||
|
|
|
|||
Loading…
Reference in New Issue