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)
|
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 () => {
|
it('briefly preserves abnormal SSH exits for paired pane recovery', async () => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
try {
|
try {
|
||||||
|
|
@ -27180,7 +27260,8 @@ describe('OrcaRuntimeService', () => {
|
||||||
getWorkspaceSession
|
getWorkspaceSession
|
||||||
} as never)
|
} 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((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|
|
||||||
|
|
@ -4561,14 +4561,14 @@ export class OrcaRuntimeService {
|
||||||
return this.startedAt
|
return this.startedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
private getWorkspaceSessionHostIdForWorktree(worktreeId: string): ExecutionHostId {
|
private tryGetWorkspaceSessionHostIdForWorktree(worktreeId: string): ExecutionHostId | null {
|
||||||
const scope = parseWorkspaceKey(worktreeId)
|
const scope = parseWorkspaceKey(worktreeId)
|
||||||
if (scope?.type === 'folder') {
|
if (scope?.type === 'folder') {
|
||||||
const workspace = this.store
|
const workspace = this.store
|
||||||
?.getFolderWorkspaces?.()
|
?.getFolderWorkspaces?.()
|
||||||
.find((entry) => entry.id === scope.folderWorkspaceId)
|
.find((entry) => entry.id === scope.folderWorkspaceId)
|
||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
throw new Error('folder_workspace_not_found')
|
return null
|
||||||
}
|
}
|
||||||
const connectionId = this.resolveFolderWorkspaceConnectionId(workspace)
|
const connectionId = this.resolveFolderWorkspaceConnectionId(workspace)
|
||||||
return connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID
|
return connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID
|
||||||
|
|
@ -4578,11 +4578,17 @@ export class OrcaRuntimeService {
|
||||||
return repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID
|
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 {
|
private getWorkspaceSessionForWorktree(worktreeId: string): WorkspaceSessionState | null {
|
||||||
return (
|
const hostId = this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId)
|
||||||
this.store?.getWorkspaceSession?.(this.getWorkspaceSessionHostIdForWorktree(worktreeId)) ??
|
return hostId ? (this.store?.getWorkspaceSession?.(hostId) ?? null) : null
|
||||||
null
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private setWorkspaceSessionForWorktree(worktreeId: string, session: WorkspaceSessionState): void {
|
private setWorkspaceSessionForWorktree(worktreeId: string, session: WorkspaceSessionState): void {
|
||||||
|
|
@ -4611,6 +4617,56 @@ export class OrcaRuntimeService {
|
||||||
return worktreeIds
|
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 {
|
getStatus(): RuntimeStatus {
|
||||||
// Why: browser panes need a backend that can create and stream a page. A
|
// 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
|
// desktop renderer provides one via <webview>; a headless serve provides one
|
||||||
|
|
@ -5475,6 +5531,8 @@ export class OrcaRuntimeService {
|
||||||
force?: boolean
|
force?: boolean
|
||||||
allowAttachedWindow?: boolean
|
allowAttachedWindow?: boolean
|
||||||
onlyRuntimeOwnedTerminals?: boolean
|
onlyRuntimeOwnedTerminals?: boolean
|
||||||
|
runtimeOwnedTerminalCandidateKnown?: boolean
|
||||||
|
workspaceSession?: WorkspaceSessionState
|
||||||
} = {}
|
} = {}
|
||||||
): Set<string> {
|
): Set<string> {
|
||||||
// Why: report which worktrees were reconciled in place so callers don't
|
// 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) {
|
if (this.getAvailableAuthoritativeWindow() && options.allowAttachedWindow !== true) {
|
||||||
return reconciledWorktreeIds
|
return reconciledWorktreeIds
|
||||||
}
|
}
|
||||||
const session = worktreeId
|
const session =
|
||||||
? this.getWorkspaceSessionForWorktree(worktreeId)
|
options.workspaceSession ??
|
||||||
: this.store?.getWorkspaceSession?.()
|
(worktreeId
|
||||||
|
? this.getWorkspaceSessionForWorktree(worktreeId)
|
||||||
|
: this.store?.getWorkspaceSession?.())
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return reconciledWorktreeIds
|
return reconciledWorktreeIds
|
||||||
}
|
}
|
||||||
|
|
@ -5497,7 +5557,14 @@ export class OrcaRuntimeService {
|
||||||
if (
|
if (
|
||||||
options.onlyRuntimeOwnedTerminals === true &&
|
options.onlyRuntimeOwnedTerminals === true &&
|
||||||
!this.offscreenBrowserBackend &&
|
!this.offscreenBrowserBackend &&
|
||||||
!this.workspaceSessionHasRuntimeOwnedPtyCandidate(session)
|
options.runtimeOwnedTerminalCandidateKnown !== true &&
|
||||||
|
!(worktreeId
|
||||||
|
? this.workspaceSessionWorktreeHasRuntimeOwnedPtyCandidate(
|
||||||
|
session,
|
||||||
|
worktreeId,
|
||||||
|
session.tabsByWorktree[worktreeId] ?? []
|
||||||
|
)
|
||||||
|
: this.workspaceSessionHasRuntimeOwnedPtyCandidate(session))
|
||||||
) {
|
) {
|
||||||
return reconciledWorktreeIds
|
return reconciledWorktreeIds
|
||||||
}
|
}
|
||||||
|
|
@ -5542,7 +5609,8 @@ export class OrcaRuntimeService {
|
||||||
}
|
}
|
||||||
const terminalTabs = this.buildHeadlessMobileSessionTerminalTabs(
|
const terminalTabs = this.buildHeadlessMobileSessionTerminalTabs(
|
||||||
entryWorktreeId,
|
entryWorktreeId,
|
||||||
persistedTabs
|
persistedTabs,
|
||||||
|
session
|
||||||
).filter(
|
).filter(
|
||||||
(tab) =>
|
(tab) =>
|
||||||
options.onlyRuntimeOwnedTerminals !== true ||
|
options.onlyRuntimeOwnedTerminals !== true ||
|
||||||
|
|
@ -5854,26 +5922,30 @@ export class OrcaRuntimeService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private workspaceSessionHasRuntimeOwnedPtyCandidate(session: WorkspaceSessionState): boolean {
|
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]) =>
|
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(
|
private getRecentExpiredSshLease(
|
||||||
worktreeId: string,
|
worktreeId: string,
|
||||||
tabId: string,
|
tabId: string,
|
||||||
|
|
@ -6482,12 +6554,9 @@ export class OrcaRuntimeService {
|
||||||
|
|
||||||
private buildHeadlessMobileSessionTerminalTabs(
|
private buildHeadlessMobileSessionTerminalTabs(
|
||||||
worktreeId: string,
|
worktreeId: string,
|
||||||
persistedTabs: readonly TerminalTab[]
|
persistedTabs: readonly TerminalTab[],
|
||||||
|
session: WorkspaceSessionState
|
||||||
): RuntimeMobileSessionTerminalTab[] {
|
): RuntimeMobileSessionTerminalTab[] {
|
||||||
const session = this.getWorkspaceSessionForWorktree(worktreeId)
|
|
||||||
if (!session) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
return [...persistedTabs]
|
return [...persistedTabs]
|
||||||
.sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt)
|
.sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt)
|
||||||
.flatMap((tab, index) => {
|
.flatMap((tab, index) => {
|
||||||
|
|
@ -6519,7 +6588,13 @@ export class OrcaRuntimeService {
|
||||||
...(tab.color != null ? { color: tab.color } : {}),
|
...(tab.color != null ? { color: tab.color } : {}),
|
||||||
...(tab.isPinned ? { isPinned: true } : {}),
|
...(tab.isPinned ? { isPinned: true } : {}),
|
||||||
...(tab.viewMode ? { viewMode: tab.viewMode } : {}),
|
...(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(
|
private isPersistedTerminalLeafActive(
|
||||||
|
session: WorkspaceSessionState,
|
||||||
worktreeId: string,
|
worktreeId: string,
|
||||||
tabId: string,
|
tabId: string,
|
||||||
leafId: string,
|
leafId: string,
|
||||||
layout: TerminalLayoutSnapshot | undefined
|
layout: TerminalLayoutSnapshot | undefined
|
||||||
): boolean {
|
): 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)
|
return activeTabId === tabId && (!layout?.activeLeafId || layout.activeLeafId === leafId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -28248,15 +28323,23 @@ export class OrcaRuntimeService {
|
||||||
missingSnapshotOnly: true,
|
missingSnapshotOnly: true,
|
||||||
notify: false
|
notify: false
|
||||||
})
|
})
|
||||||
const worktreeIdsToHydrate = this.getKnownWorkspaceSessionWorktreeIds()
|
// Why: graph sync must scan each persisted host session once, not once per workspace.
|
||||||
for (const snapshot of snapshots) {
|
const worktreeSessionsToHydrate = new Map<string, WorkspaceSessionState | null>(
|
||||||
worktreeIdsToHydrate.add(snapshot.worktree)
|
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.
|
// 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, {
|
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, {
|
||||||
allowAttachedWindow: true,
|
allowAttachedWindow: true,
|
||||||
onlyRuntimeOwnedTerminals: true
|
onlyRuntimeOwnedTerminals: true,
|
||||||
|
...(workspaceSession ? { runtimeOwnedTerminalCandidateKnown: true, workspaceSession } : {})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const nextWorktrees = new Set<string>()
|
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)
|
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', () => {
|
it('changes when explicit agent status epoch changes', () => {
|
||||||
const sharedOverrides = makeSharedOverrides()
|
const sharedOverrides = makeSharedOverrides()
|
||||||
const before = getRuntimeMobileSessionSyncKey(
|
const before = getRuntimeMobileSessionSyncKey(
|
||||||
|
|
@ -596,6 +619,54 @@ describe('getRuntimeMobileSessionSyncKey', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('buildMobileSessionTabSnapshots', () => {
|
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', () => {
|
it('publishes browser and editor color + pin state from unified tabs', () => {
|
||||||
const fileId = '/repo/README.md'
|
const fileId = '/repo/README.md'
|
||||||
const state = makeState({
|
const state = makeState({
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import type {
|
||||||
import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id'
|
import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id'
|
||||||
import { isWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id'
|
import { isWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id'
|
||||||
import { isClaudeManagementTitle } from '../../../shared/agent-detection'
|
import { isClaudeManagementTitle } from '../../../shared/agent-detection'
|
||||||
|
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
|
||||||
import type {
|
import type {
|
||||||
Tab,
|
Tab,
|
||||||
TabGroup,
|
TabGroup,
|
||||||
|
|
@ -247,6 +248,7 @@ export type RuntimeMobileSessionSyncKey = {
|
||||||
terminalLayoutsByTabId: AppState['terminalLayoutsByTabId']
|
terminalLayoutsByTabId: AppState['terminalLayoutsByTabId']
|
||||||
runtimePaneTitlesByTabId: AppState['runtimePaneTitlesByTabId']
|
runtimePaneTitlesByTabId: AppState['runtimePaneTitlesByTabId']
|
||||||
nativeChatLaunchDraftByTabId: AppState['nativeChatLaunchDraftByTabId']
|
nativeChatLaunchDraftByTabId: AppState['nativeChatLaunchDraftByTabId']
|
||||||
|
folderWorkspaces: AppState['folderWorkspaces']
|
||||||
groupsByWorktree: AppState['groupsByWorktree']
|
groupsByWorktree: AppState['groupsByWorktree']
|
||||||
activeGroupIdByWorktree: AppState['activeGroupIdByWorktree']
|
activeGroupIdByWorktree: AppState['activeGroupIdByWorktree']
|
||||||
layoutByWorktree: AppState['layoutByWorktree']
|
layoutByWorktree: AppState['layoutByWorktree']
|
||||||
|
|
@ -303,6 +305,7 @@ export function canSkipRuntimeMobileSessionSyncKeyBuild(
|
||||||
state.terminalLayoutsByTabId === previousState.terminalLayoutsByTabId &&
|
state.terminalLayoutsByTabId === previousState.terminalLayoutsByTabId &&
|
||||||
state.runtimePaneTitlesByTabId === previousState.runtimePaneTitlesByTabId &&
|
state.runtimePaneTitlesByTabId === previousState.runtimePaneTitlesByTabId &&
|
||||||
state.nativeChatLaunchDraftByTabId === previousState.nativeChatLaunchDraftByTabId &&
|
state.nativeChatLaunchDraftByTabId === previousState.nativeChatLaunchDraftByTabId &&
|
||||||
|
state.folderWorkspaces === previousState.folderWorkspaces &&
|
||||||
state.agentStatusEpoch === previousState.agentStatusEpoch &&
|
state.agentStatusEpoch === previousState.agentStatusEpoch &&
|
||||||
state.agentStatusByPaneKey === previousState.agentStatusByPaneKey
|
state.agentStatusByPaneKey === previousState.agentStatusByPaneKey
|
||||||
)
|
)
|
||||||
|
|
@ -340,6 +343,7 @@ export function getRuntimeMobileSessionSyncKey(
|
||||||
terminalLayoutsByTabId: state.terminalLayoutsByTabId,
|
terminalLayoutsByTabId: state.terminalLayoutsByTabId,
|
||||||
runtimePaneTitlesByTabId: state.runtimePaneTitlesByTabId,
|
runtimePaneTitlesByTabId: state.runtimePaneTitlesByTabId,
|
||||||
nativeChatLaunchDraftByTabId: state.nativeChatLaunchDraftByTabId,
|
nativeChatLaunchDraftByTabId: state.nativeChatLaunchDraftByTabId,
|
||||||
|
folderWorkspaces: state.folderWorkspaces,
|
||||||
groupsByWorktree: state.groupsByWorktree,
|
groupsByWorktree: state.groupsByWorktree,
|
||||||
activeGroupIdByWorktree: state.activeGroupIdByWorktree,
|
activeGroupIdByWorktree: state.activeGroupIdByWorktree,
|
||||||
layoutByWorktree: state.layoutByWorktree ?? EMPTY_LAYOUT_BY_WORKTREE,
|
layoutByWorktree: state.layoutByWorktree ?? EMPTY_LAYOUT_BY_WORKTREE,
|
||||||
|
|
@ -585,6 +589,7 @@ export function runtimeMobileSessionSyncKeysEqual(
|
||||||
a.terminalLayoutsByTabId === b.terminalLayoutsByTabId &&
|
a.terminalLayoutsByTabId === b.terminalLayoutsByTabId &&
|
||||||
a.runtimePaneTitlesByTabId === b.runtimePaneTitlesByTabId &&
|
a.runtimePaneTitlesByTabId === b.runtimePaneTitlesByTabId &&
|
||||||
a.nativeChatLaunchDraftByTabId === b.nativeChatLaunchDraftByTabId &&
|
a.nativeChatLaunchDraftByTabId === b.nativeChatLaunchDraftByTabId &&
|
||||||
|
a.folderWorkspaces === b.folderWorkspaces &&
|
||||||
a.groupsByWorktree === b.groupsByWorktree &&
|
a.groupsByWorktree === b.groupsByWorktree &&
|
||||||
a.activeGroupIdByWorktree === b.activeGroupIdByWorktree &&
|
a.activeGroupIdByWorktree === b.activeGroupIdByWorktree &&
|
||||||
a.layoutByWorktree === b.layoutByWorktree &&
|
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.
|
// 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 openFileIndexes = getOpenFileIndexes(state.openFiles)
|
||||||
const editorDraftVersionByFileId = getEditorDraftVersionByFileId(state.editorDrafts)
|
const editorDraftVersionByFileId = getEditorDraftVersionByFileId(state.editorDrafts)
|
||||||
|
const liveFolderWorkspaceIds = new Set(
|
||||||
|
(state.folderWorkspaces ?? []).map((workspace) => workspace.id)
|
||||||
|
)
|
||||||
const worktreeIds = new Set<string>([
|
const worktreeIds = new Set<string>([
|
||||||
...Object.keys(state.tabsByWorktree),
|
...Object.keys(state.tabsByWorktree),
|
||||||
...Object.keys(state.groupsByWorktree),
|
...Object.keys(state.groupsByWorktree),
|
||||||
|
|
@ -767,6 +775,14 @@ export function buildMobileSessionTabSnapshots(
|
||||||
|
|
||||||
const snapshots: RuntimeMobileSessionTabsSnapshot[] = []
|
const snapshots: RuntimeMobileSessionTabsSnapshot[] = []
|
||||||
for (const worktreeId of worktreeIds) {
|
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 activeGroupId = state.activeGroupIdByWorktree[worktreeId] ?? null
|
||||||
const terminalTabByIdForWorktree = new Map(
|
const terminalTabByIdForWorktree = new Map(
|
||||||
(state.tabsByWorktree[worktreeId] ?? []).map((tab) => [tab.id, tab])
|
(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 { getRemoteRuntimePtyEnvironmentId, toRemoteRuntimePtyId } from './runtime-terminal-stream'
|
||||||
import { sanitizeTerminalLayoutPaneTitlesForLabels } from '@/lib/terminal-pane-title-sanitization'
|
import { sanitizeTerminalLayoutPaneTitlesForLabels } from '@/lib/terminal-pane-title-sanitization'
|
||||||
import { normalizeTerminalLayoutPtyOwnership } from '@/components/terminal-pane/terminal-layout-pty-ownership'
|
import { normalizeTerminalLayoutPtyOwnership } from '@/components/terminal-pane/terminal-layout-pty-ownership'
|
||||||
import {
|
import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||||
getExplicitRuntimeEnvironmentIdForWorktree,
|
import { getReachableRuntimeSessionMirrorTargets } from '@/lib/runtime-session-mirror-targets'
|
||||||
getRuntimeSessionMirrorEnvironmentIds
|
|
||||||
} from '@/lib/worktree-runtime-owner'
|
|
||||||
import {
|
import {
|
||||||
createWebRuntimeSessionTerminal,
|
createWebRuntimeSessionTerminal,
|
||||||
HOST_TERMINAL_SURFACE_SEPARATOR,
|
HOST_TERMINAL_SURFACE_SEPARATOR,
|
||||||
|
|
@ -2694,17 +2692,11 @@ export function applyWebSessionTabsStorePatch(
|
||||||
export function useWebSessionTabsSync(): void {
|
export function useWebSessionTabsSync(): void {
|
||||||
const activeWorktreeId = useAppStore((state) => state.activeWorktreeId)
|
const activeWorktreeId = useAppStore((state) => state.activeWorktreeId)
|
||||||
const runtimeSessionMirrorEnvironmentKey = useAppStore((state) =>
|
const runtimeSessionMirrorEnvironmentKey = useAppStore((state) =>
|
||||||
getRuntimeSessionMirrorEnvironmentIds(state)
|
getReachableRuntimeSessionMirrorTargets(state)
|
||||||
.map((environmentId) => {
|
.map(
|
||||||
const status = state.runtimeStatusByEnvironmentId.get(environmentId)
|
({ environmentId, runtimeId, connectionGeneration, pairingRevision }) =>
|
||||||
const environment = state.runtimeEnvironments.find(
|
`${environmentId}\u0001${runtimeId}\u0001${connectionGeneration}\u0001${pairingRevision}`
|
||||||
(candidate) => candidate.id === environmentId
|
)
|
||||||
)
|
|
||||||
const pairingRevision = environment
|
|
||||||
? (environment.pairingRevision ?? environment.createdAt)
|
|
||||||
: ''
|
|
||||||
return `${environmentId}\u0001${status?.status?.runtimeId ?? ''}\u0001${status?.connectionGeneration ?? 0}\u0001${pairingRevision}`
|
|
||||||
})
|
|
||||||
.join('\u0000')
|
.join('\u0000')
|
||||||
)
|
)
|
||||||
const activeWorktreeRuntimeEnvironmentId = useAppStore((state) =>
|
const activeWorktreeRuntimeEnvironmentId = useAppStore((state) =>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue