mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
fix(runtime): keep the renderer epoch and sanitise persisted groups on the chat-only fall-through
This commit is contained in:
+38
-16
@@ -1,6 +1,7 @@
|
||||
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
||||
import { OrcaRuntimeWithWaitForSessionTabsInventoryPublication } from './orca-runtime-wait-for-session-tabs-inventory-publication'
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
||||
import type { TabGroup } from '../../shared/tab-types'
|
||||
import { getRuntimeBrowserPageRegistry } from './runtime-browser-page-registry'
|
||||
import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id'
|
||||
import { buildHeadlessMobileSessionTerminalTabs } from './mobile-session-terminal-projection'
|
||||
@@ -28,6 +29,17 @@ import {
|
||||
} from './mobile-session-browser-group-projection'
|
||||
import { headlessMobileSnapshotContentUnchanged } from './mobile-session-snapshot-equality'
|
||||
|
||||
// Why: a persisted TabGroup carries `worktreeId`, which the published group
|
||||
// shape does not — spreading one onto the wire leaks it to every client.
|
||||
function toRuntimeMobileSessionTabGroup(group: TabGroup): RuntimeMobileSessionTabGroup {
|
||||
return {
|
||||
id: group.id,
|
||||
activeTabId: group.activeTabId,
|
||||
tabOrder: [...group.tabOrder],
|
||||
...(group.recentTabIds ? { recentTabIds: [...group.recentTabIds] } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export class OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspaceSession extends OrcaRuntimeWithWaitForSessionTabsInventoryPublication {
|
||||
protected hydrateHeadlessMobileSessionTabsFromWorkspaceSession(
|
||||
worktreeId?: string,
|
||||
@@ -142,12 +154,18 @@ export class OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspaceSession
|
||||
...browserTabs.map((tab) => tab.id)
|
||||
]
|
||||
const groupId = getHeadlessMobileSessionGroupId(entryWorktreeId)
|
||||
// Why: `tabs` already carries every live browser, so merging the existing
|
||||
// browser entries back in resurrects a page that closed while the
|
||||
// snapshot was chat-only (the skip branch's reconcile used to prune it).
|
||||
const existingMergeBaseTabs = preserveNonTerminalSnapshot
|
||||
? existing.tabs.filter((tab) => tab.type !== 'browser')
|
||||
: existing?.tabs
|
||||
const mergedTabs =
|
||||
(options.onlyRuntimeOwnedTerminals === true || preserveNonTerminalSnapshot) && existing
|
||||
? mergeMobileSessionSnapshotTabs(existing.tabs, tabs)
|
||||
? mergeMobileSessionSnapshotTabs(existingMergeBaseTabs, tabs)
|
||||
: tabs
|
||||
const mergedActiveTab =
|
||||
existing?.tabs.find((tab) => tab.id === existing.activeTabId) ??
|
||||
existingMergeBaseTabs?.find((tab) => tab.id === existing?.activeTabId) ??
|
||||
activeTab ??
|
||||
mergedTabs[0] ??
|
||||
null
|
||||
@@ -177,17 +195,12 @@ export class OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspaceSession
|
||||
entryWorktreeId,
|
||||
mergedTabs,
|
||||
mergedActiveTab,
|
||||
existing.tabGroups ?? persistedGroups
|
||||
existing.tabGroups ?? persistedGroups?.map(toRuntimeMobileSessionTabGroup)
|
||||
)
|
||||
: hasPersistedSplit
|
||||
? appendBrowserTabOrder(
|
||||
distributeHeadlessTabsAcrossGroups(
|
||||
persistedGroups.map((group) => ({
|
||||
id: group.id,
|
||||
activeTabId: group.activeTabId,
|
||||
tabOrder: [...group.tabOrder],
|
||||
...(group.recentTabIds ? { recentTabIds: [...group.recentTabIds] } : {})
|
||||
})),
|
||||
persistedGroups.map(toRuntimeMobileSessionTabGroup),
|
||||
collectHeadlessParentTabOrder(mergedTerminalTabs),
|
||||
activeTopLevelId
|
||||
),
|
||||
@@ -216,22 +229,31 @@ export class OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspaceSession
|
||||
tabOrder
|
||||
}
|
||||
]
|
||||
// Why: merging runtime tabs INTO a renderer publication must not reclass
|
||||
// the snapshot as headless-built — the preservation predicate would then
|
||||
// treat the renderer's own tabs as runtime-owned and resurrect tabs the
|
||||
// renderer later closes. Keep the renderer base epoch with a merge suffix
|
||||
// (idempotent) so ownership stays derivable from the epoch.
|
||||
// Why: merging INTO a non-headless publication must not reclass the
|
||||
// snapshot as headless-built — the preservation predicate would then treat
|
||||
// that publisher's own tabs as runtime-owned and resurrect tabs it later
|
||||
// closes. Applies to both merge paths: the chat-only fall-through also
|
||||
// merges into an existing snapshot, whose base epoch can be a renderer's.
|
||||
const mergedIntoRendererPublication =
|
||||
options.onlyRuntimeOwnedTerminals === true &&
|
||||
(options.onlyRuntimeOwnedTerminals === true || preserveNonTerminalSnapshot) &&
|
||||
existing !== undefined &&
|
||||
!this.isHeadlessBuiltMobileSessionPublicationBase(existing.publicationEpoch)
|
||||
// Why: a group whose last tab is gone is dropped above, so a carried-over
|
||||
// activeGroupId can name a group that no longer exists — reseat it on the
|
||||
// group holding the active tab rather than publishing a dangling id.
|
||||
const preservedActiveGroupId = existing?.activeGroupId ?? groupId
|
||||
const nextActiveGroupId = nextTabGroups.some((group) => group.id === preservedActiveGroupId)
|
||||
? preservedActiveGroupId
|
||||
: (nextTabGroups.find((group) => group.tabOrder.includes(activeTopLevelId))?.id ??
|
||||
nextTabGroups[0]?.id ??
|
||||
preservedActiveGroupId)
|
||||
const nextSnapshot: RuntimeMobileSessionTabsSnapshot = {
|
||||
worktree: existing?.worktree ?? entryWorktreeId,
|
||||
publicationEpoch: mergedIntoRendererPublication
|
||||
? this.getMergedMobileSessionPublicationEpoch(existing, tabs)
|
||||
: `headless-hydrated:${Date.now().toString(36)}`,
|
||||
snapshotVersion: (existing?.snapshotVersion ?? 0) + 1,
|
||||
activeGroupId: existing?.activeGroupId ?? groupId,
|
||||
activeGroupId: nextActiveGroupId,
|
||||
activeTabId: mergedActiveTab?.id ?? null,
|
||||
activeTabType: mergedActiveTab?.type ?? null,
|
||||
tabGroups: nextTabGroups,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultWorkspaceSession } from '../../shared/constants'
|
||||
import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types'
|
||||
import type {
|
||||
RuntimeMobileSessionSnapshotTab,
|
||||
RuntimeMobileSessionTabsSnapshot
|
||||
} from '../../shared/runtime-types'
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
||||
import * as terminalProjection from './mobile-session-terminal-projection'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
@@ -12,10 +15,15 @@ type RuntimeInternals = {
|
||||
getAvailableAuthoritativeWindow(): unknown
|
||||
getWorkspaceSessionForWorktree(worktreeId: string): WorkspaceSessionState
|
||||
mobileSessionTabsByWorktree: Map<string, RuntimeMobileSessionTabsSnapshot>
|
||||
buildHeadlessMobileSessionBrowserTabs: () => never[]
|
||||
buildHeadlessMobileSessionBrowserTabs: () => RuntimeMobileSessionSnapshotTab[]
|
||||
reconcileHeadlessMobileSessionBrowserTabs: () => void
|
||||
hasServeOrSshOwnedBinding(tab: { ptyId?: string }): boolean
|
||||
hasRecentExpiredSshLeasePane(): boolean
|
||||
isHeadlessBuiltMobileSessionPublicationBase(publicationEpoch: string): boolean
|
||||
shouldPreserveHeadlessMobileSessionTab(
|
||||
snapshot: RuntimeMobileSessionTabsSnapshot,
|
||||
tab: RuntimeMobileSessionSnapshotTab
|
||||
): boolean
|
||||
hydrateHeadlessMobileSessionTabsFromWorkspaceSession(
|
||||
worktreeId: string,
|
||||
options: {
|
||||
@@ -27,6 +35,21 @@ type RuntimeInternals = {
|
||||
): Set<string>
|
||||
}
|
||||
|
||||
function browserTab(): RuntimeMobileSessionSnapshotTab {
|
||||
return {
|
||||
type: 'browser',
|
||||
id: 'browser',
|
||||
browserWorkspaceId: 'page',
|
||||
browserPageId: 'browser',
|
||||
loading: false,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
title: 'Page',
|
||||
url: 'about:blank',
|
||||
isActive: false
|
||||
}
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const runtime = new OrcaRuntimeService() as unknown as RuntimeInternals
|
||||
const session: WorkspaceSessionState = {
|
||||
@@ -81,18 +104,10 @@ describe('persisted terminal hydration behind non-terminal snapshots', () => {
|
||||
it.each([false, true])('preserves the active chat and its group (browser split: %s)', (split) => {
|
||||
const { runtime, session, snapshot } = setup()
|
||||
if (split) {
|
||||
snapshot.tabs.push({
|
||||
type: 'browser',
|
||||
id: 'browser',
|
||||
browserWorkspaceId: 'page',
|
||||
browserPageId: 'browser',
|
||||
loading: false,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
title: 'Page',
|
||||
url: 'about:blank',
|
||||
isActive: false
|
||||
})
|
||||
const browser = browserTab()
|
||||
snapshot.tabs.push(browser)
|
||||
// The page is still live, so the rebuild republishes it.
|
||||
runtime.buildHeadlessMobileSessionBrowserTabs = vi.fn(() => [browser])
|
||||
snapshot.tabGroups!.unshift({
|
||||
id: 'browser-group',
|
||||
activeTabId: 'browser',
|
||||
@@ -213,9 +228,99 @@ describe('persisted terminal hydration behind non-terminal snapshots', () => {
|
||||
allowAttachedWindow: true,
|
||||
force: true
|
||||
})
|
||||
expect(runtime.mobileSessionTabsByWorktree.get(WORKTREE)!.tabs.map((tab) => tab.type)).toEqual([
|
||||
'terminal',
|
||||
'terminal'
|
||||
])
|
||||
const result = runtime.mobileSessionTabsByWorktree.get(WORKTREE)!
|
||||
expect(result.tabs.map((tab) => tab.type)).toEqual(['terminal', 'terminal'])
|
||||
// Guards the epoch fix below from over-correcting: a plain rebuild that
|
||||
// merges into nothing is headless-built and must say so.
|
||||
expect(result.publicationEpoch.startsWith('headless-hydrated:')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('chat-only fall-through hygiene', () => {
|
||||
const RENDERER_EPOCH = 'renderer:6b1f0f5c-0b6a-4d31-9d1f-6a0f1d2c3b4e'
|
||||
|
||||
it('carries a renderer base epoch forward instead of reclassing as headless-built', () => {
|
||||
const { runtime, snapshot } = setup()
|
||||
snapshot.publicationEpoch = RENDERER_EPOCH
|
||||
|
||||
runtime.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(WORKTREE, {
|
||||
allowAttachedWindow: true
|
||||
})
|
||||
|
||||
const result = runtime.mobileSessionTabsByWorktree.get(WORKTREE)!
|
||||
expect(result.publicationEpoch).toBe(RENDERER_EPOCH)
|
||||
expect(runtime.isHeadlessBuiltMobileSessionPublicationBase(result.publicationEpoch)).toBe(false)
|
||||
const hydrated = result.tabs.find((tab) => tab.type === 'terminal')!
|
||||
expect(runtime.shouldPreserveHeadlessMobileSessionTab(result, hydrated)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a headless base epoch headless-built', () => {
|
||||
const { runtime, snapshot } = setup()
|
||||
snapshot.publicationEpoch = 'headless:seed'
|
||||
|
||||
runtime.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(WORKTREE, {
|
||||
allowAttachedWindow: true
|
||||
})
|
||||
|
||||
const result = runtime.mobileSessionTabsByWorktree.get(WORKTREE)!
|
||||
expect(result.publicationEpoch.startsWith('headless-hydrated:')).toBe(true)
|
||||
expect(runtime.isHeadlessBuiltMobileSessionPublicationBase(result.publicationEpoch)).toBe(true)
|
||||
})
|
||||
|
||||
it('publishes persisted groups in the wire shape, without worktreeId', () => {
|
||||
const { runtime, session, snapshot } = setup()
|
||||
delete snapshot.tabGroups
|
||||
session.tabGroups = {
|
||||
[WORKTREE]: ['left', 'right'].map((id) => ({
|
||||
id,
|
||||
worktreeId: WORKTREE,
|
||||
activeTabId: null,
|
||||
tabOrder: [],
|
||||
recentTabIds: []
|
||||
}))
|
||||
}
|
||||
|
||||
runtime.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(WORKTREE, {
|
||||
allowAttachedWindow: true
|
||||
})
|
||||
|
||||
const result = runtime.mobileSessionTabsByWorktree.get(WORKTREE)!
|
||||
expect(result.tabGroups!.length).toBeGreaterThan(0)
|
||||
for (const group of result.tabGroups!) {
|
||||
expect(Object.keys(group)).not.toContain('worktreeId')
|
||||
}
|
||||
})
|
||||
|
||||
it('drops a browser tab whose page is gone', () => {
|
||||
const { runtime, snapshot } = setup()
|
||||
snapshot.tabs.push(browserTab())
|
||||
snapshot.tabGroups![0]!.tabOrder.push('browser')
|
||||
|
||||
runtime.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(WORKTREE, {
|
||||
allowAttachedWindow: true
|
||||
})
|
||||
|
||||
const result = runtime.mobileSessionTabsByWorktree.get(WORKTREE)!
|
||||
expect(result.tabs.map((tab) => tab.id)).not.toContain('browser')
|
||||
expect(result.tabs.some((tab) => tab.id === CHAT)).toBe(true)
|
||||
expect(result.tabGroups!.flatMap((group) => group.tabOrder)).not.toContain('browser')
|
||||
})
|
||||
|
||||
it('reseats the active group when the stale browser emptied it', () => {
|
||||
const { runtime, snapshot } = setup()
|
||||
snapshot.tabs.push(browserTab())
|
||||
snapshot.tabGroups!.push({ id: 'browser-group', activeTabId: 'browser', tabOrder: ['browser'] })
|
||||
snapshot.activeGroupId = 'browser-group'
|
||||
snapshot.activeTabId = 'browser'
|
||||
snapshot.activeTabType = 'browser'
|
||||
|
||||
runtime.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(WORKTREE, {
|
||||
allowAttachedWindow: true
|
||||
})
|
||||
|
||||
const result = runtime.mobileSessionTabsByWorktree.get(WORKTREE)!
|
||||
expect(result.tabGroups!.map((group) => group.id)).not.toContain('browser-group')
|
||||
expect(result.tabGroups!.some((group) => group.id === result.activeGroupId)).toBe(true)
|
||||
expect(result.tabs.some((tab) => tab.id === result.activeTabId)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user