Record a display-only snapshot of each paired server's tabs (inert) (#27359)

* feat(layout): record a display-only snapshot of each paired server's tabs

Records the session.tabs.subscribeAll frames main already relays to the window
into a new remoteLayoutSnapshots profile domain (design 6.3, D3). No new server
requests; written only by the recorder; dropped when the server is removed.
Nothing reads it yet.

* fix(layout): keep only what the snapshot draws and save it on structure changes

Drop retirement proofs (sent as deltas) and one-shot navigation from recorded
workspaces; save immediately only when tabs or groups change, letting title
ticks ride the next save; drop the protocol field.
This commit is contained in:
Jinwoo Hong
2026-10-10 23:36:37 -04:00
committed by GitHub
parent e0625521ac
commit 666d990bc0
21 changed files with 695 additions and 28 deletions
+7 -9
View File
@@ -9,8 +9,10 @@ import {
updateManagedOrcadEnvironment
} from '../ssh/orcad-runtime-lifecycle'
import type { ManagedServerActions } from '../runtime/managed-server-actions-registry'
import type { ExecutionHostId } from '../../shared/execution-host'
import { retireRemovedRuntimeEnvironment } from './runtime-environment-removal-cleanup'
import {
retireRemovedRuntimeEnvironment,
type RemovedServerStore
} from './runtime-environment-removal-cleanup'
export type ManagedOrcadActionOptions = {
getUserDataPath: () => string
@@ -20,8 +22,8 @@ export type ManagedOrcadActionOptions = {
clearHostServerNotes: (sshTargetId: string, environmentId: string) => void
/** Drops the SSH host's stale managed-server state once its server is unlinked. */
clearHostServerStatus: (sshTargetId: string) => void
/** Drops the unlinked server's workspace session partition. */
forgetHostSession: (hostId: ExecutionHostId) => void
/** Holds what an unlinked server leaves in the profile. */
store: RemovedServerStore
}
export function createManagedOrcadActions(
@@ -32,11 +34,7 @@ export function createManagedOrcadActions(
isActiveEnvironment: (environmentId: string) =>
options.getActiveEnvironmentId() === environmentId,
retireLocalState: (environmentId: string) =>
retireRemovedRuntimeEnvironment(
environmentId,
options.invalidateTransport,
options.forgetHostSession
)
retireRemovedRuntimeEnvironment(environmentId, options.invalidateTransport, options.store)
}
return {
status: (selector) => getManagedOrcadRuntimeStatus(userDataPath(), selector),
@@ -28,7 +28,11 @@ const { registerOrcadRuntimeMaintenanceHandlers } =
const invalidateTransport = vi.fn()
const clearHostServerStatus = vi.fn()
const clearHostServerNotes = vi.fn()
const forgetHostSession = vi.fn()
const store = {
removeWorkspaceSessionHost: vi.fn(),
getRemoteLayoutSnapshot: vi.fn(),
setRemoteLayoutSnapshot: vi.fn()
}
function handler(channel: string): (_event: unknown, args: unknown) => Promise<unknown> {
const registration = mocks.handle.mock.calls.find(([name]) => name === channel)
@@ -47,7 +51,7 @@ describe('managed orcad maintenance IPC', () => {
invalidateTransport,
clearHostServerStatus,
clearHostServerNotes,
forgetHostSession
store
})
})
@@ -124,7 +128,7 @@ describe('managed orcad maintenance IPC', () => {
expect(policy.isActiveEnvironment('active-environment')).toBe(true)
expect(policy.isActiveEnvironment('e-1')).toBe(false)
policy.retireLocalState('e-1')
expect(mocks.retire).toHaveBeenCalledWith('e-1', invalidateTransport, forgetHostSession)
expect(mocks.retire).toHaveBeenCalledWith('e-1', invalidateTransport, store)
// The SSH host stops naming the unlinked server without waiting for a reconnect.
expect(clearHostServerStatus).toHaveBeenCalledWith('ssh-1')
})
@@ -0,0 +1,194 @@
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import type { RemoteLayoutSnapshot } from '../../shared/remote-layout-snapshot'
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
import { scanSourceTree, stripComments } from '../../shared/source-scan/source-tree-scan'
import {
forgetRemoteLayoutSnapshot,
recordRemoteLayoutFrame,
type RemoteLayoutSnapshotStore
} from './remote-layout-snapshot-recorder'
const METHOD = 'session.tabs.subscribeAll'
function memoryStore() {
const snapshots = new Map<string, RemoteLayoutSnapshot>()
const saves: ('now' | 'later')[] = []
const store: RemoteLayoutSnapshotStore = {
getRemoteLayoutSnapshot: (environmentId) => snapshots.get(environmentId),
setRemoteLayoutSnapshot: (environmentId, snapshot, save) => {
saves.push(save)
if (snapshot) {
snapshots.set(environmentId, snapshot)
} else {
snapshots.delete(environmentId)
}
}
}
return { store, snapshots, saves }
}
function tabs(worktree: string, title = 'shell') {
return {
worktree,
publicationEpoch: 'epoch',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: [{ type: 'terminal', id: `${worktree}-tab`, title }]
}
}
function frame(result: unknown, runtimeId = 'server-1'): RuntimeRpcResponse<unknown> {
return { id: 'stream', ok: true, result, _meta: { runtimeId } }
}
function workspacesOf(snapshots: Map<string, RemoteLayoutSnapshot>, environmentId = 'env') {
return snapshots.get(environmentId)?.workspaces
}
describe('remote layout snapshot recorder', () => {
it('replaces the whole entry on each census, dropping worktrees it no longer lists', () => {
const { store, snapshots } = memoryStore()
recordRemoteLayoutFrame(
store,
'env',
METHOD,
frame({ type: 'snapshots', snapshots: [tabs('a'), tabs('b')] })
)
recordRemoteLayoutFrame(
store,
'env',
METHOD,
frame({ type: 'snapshots', snapshots: [tabs('b', 'renamed')] }, 'server-2')
)
expect(snapshots.get('env')).toMatchObject({
serverRuntimeId: 'server-2',
workspaces: { b: tabs('b', 'renamed') }
})
expect(workspacesOf(snapshots)).not.toHaveProperty('a')
})
it('replaces one worktree on an update and drops it on a removal', () => {
const { store, snapshots } = memoryStore()
recordRemoteLayoutFrame(
store,
'env',
METHOD,
frame({ type: 'snapshots', snapshots: [tabs('a'), tabs('b')] })
)
recordRemoteLayoutFrame(store, 'env', METHOD, frame({ type: 'updated', ...tabs('a', 'vim') }))
expect(workspacesOf(snapshots)).toEqual({ a: tabs('a', 'vim'), b: tabs('b') })
recordRemoteLayoutFrame(
store,
'env',
METHOD,
frame({ type: 'updated', ...tabs('b'), tabs: [], removed: true })
)
expect(workspacesOf(snapshots)).toEqual({ a: tabs('a', 'vim') })
})
it('keeps neither retirement proofs nor one-shot navigation', () => {
const { store, snapshots } = memoryStore()
const proof = { parentTabId: 'a-tab', leafId: 'leaf', ptyId: 'pty', terminal: 'term' }
recordRemoteLayoutFrame(
store,
'env',
METHOD,
frame({ type: 'snapshots', snapshots: [{ ...tabs('a'), retiredTerminalSurfaces: [proof] }] })
)
expect(workspacesOf(snapshots)).toEqual({ a: tabs('a') })
// A capable client is sent only new proofs, so an update's list is a delta, here empty.
recordRemoteLayoutFrame(
store,
'env',
METHOD,
frame({
type: 'updated',
...tabs('a', 'vim'),
retiredTerminalSurfaces: [],
navigationIntent: 'follow'
})
)
expect(workspacesOf(snapshots)).toEqual({ a: tabs('a', 'vim') })
})
it('saves at once only when tabs or groups change; title ticks ride the next save', () => {
const { store, saves } = memoryStore()
const census = (snapshots: unknown[]) => frame({ type: 'snapshots', snapshots })
const update = (result: Record<string, unknown>) => frame({ type: 'updated', ...result })
recordRemoteLayoutFrame(store, 'env', METHOD, census([tabs('a')]))
recordRemoteLayoutFrame(store, 'env', METHOD, update(tabs('a', 'thinking.')))
recordRemoteLayoutFrame(store, 'env', METHOD, update(tabs('a', 'thinking..')))
const twoTabs = {
...tabs('a'),
tabs: [...tabs('a').tabs, { type: 'terminal', id: 'second', title: 'shell' }]
}
recordRemoteLayoutFrame(store, 'env', METHOD, update(twoTabs))
const grouped = {
...twoTabs,
tabGroups: [{ id: 'g', activeTabId: null, tabOrder: ['second'] }]
}
recordRemoteLayoutFrame(store, 'env', METHOD, update(grouped))
recordRemoteLayoutFrame(
store,
'env',
METHOD,
update({ ...grouped, tabGroups: [{ id: 'g', activeTabId: 'second', tabOrder: ['second'] }] })
)
recordRemoteLayoutFrame(store, 'env', METHOD, update({ ...tabs('a'), tabs: [], removed: true }))
expect(saves).toEqual(['now', 'later', 'later', 'now', 'now', 'later', 'now'])
})
it('keeps each server in its own entry', () => {
const { store, snapshots } = memoryStore()
recordRemoteLayoutFrame(
store,
'one',
METHOD,
frame({ type: 'snapshots', snapshots: [tabs('a')] })
)
recordRemoteLayoutFrame(store, 'two', METHOD, frame({ type: 'snapshots', snapshots: [] }))
expect(workspacesOf(snapshots, 'one')).toEqual({ a: tabs('a') })
expect(workspacesOf(snapshots, 'two')).toEqual({})
})
it('records nothing from other streams, failures, ends or unreadable frames', () => {
const { store, saves } = memoryStore()
const census = { type: 'snapshots', snapshots: [tabs('a')] }
recordRemoteLayoutFrame(store, 'env', 'session.tabs.subscribe', frame(census))
recordRemoteLayoutFrame(store, 'env', 'terminal.multiplex', frame(census))
recordRemoteLayoutFrame(store, 'env', METHOD, {
id: 'stream',
ok: false,
error: { code: 'method_not_found', message: 'nope' },
_meta: { runtimeId: 'server-1' }
})
recordRemoteLayoutFrame(store, 'env', METHOD, frame({ type: 'end' }))
recordRemoteLayoutFrame(store, 'env', METHOD, frame({ type: 'snapshots', snapshots: [{}] }))
recordRemoteLayoutFrame(store, 'env', METHOD, frame({ type: 'updated', worktree: 'a' }))
expect(saves).toEqual([])
})
it('forgets a removed server', () => {
const { store, snapshots } = memoryStore()
recordRemoteLayoutFrame(store, 'env', METHOD, frame({ type: 'snapshots', snapshots: [] }))
forgetRemoteLayoutSnapshot(store, 'env')
expect(snapshots.has('env')).toBe(false)
})
it('is the only code that writes the snapshot', () => {
const src = join(__dirname, '../..')
const writers = scanSourceTree(src)
.filter(({ source }) => stripComments(source).includes('setRemoteLayoutSnapshot('))
.map(({ relativePath }) => relativePath)
expect(writers.sort()).toEqual([
'main/ipc/remote-layout-snapshot-recorder.ts',
'main/persistence/loading-store/remote-layout-snapshot-persistence.ts'
])
})
})
@@ -0,0 +1,104 @@
// The one writer of the display-only server layout snapshot (design 6.3, D3). It records the
// `session.tabs.subscribeAll` frames main already relays to the window, so no server receives a
// new request. Nothing reads the snapshot yet.
import type {
RemoteLayoutSnapshot,
RemoteLayoutSnapshotWorkspace
} from '../../shared/remote-layout-snapshot'
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-session-contracts'
export type RemoteLayoutSnapshotStore = {
getRemoteLayoutSnapshot(environmentId: string): RemoteLayoutSnapshot | undefined
setRemoteLayoutSnapshot(
environmentId: string,
snapshot: RemoteLayoutSnapshot | null,
save: 'now' | 'later'
): void
}
type SessionTabsFrame =
| { type: 'snapshots'; snapshots: RuntimeMobileSessionTabsResult[] }
| (RuntimeMobileSessionTabsResult & { type: 'updated'; removed?: true })
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
// Checks the identity fields only; the rest is the server's published contract.
function isSessionTabsResult(value: unknown): value is RuntimeMobileSessionTabsResult {
return isRecord(value) && typeof value.worktree === 'string' && Array.isArray(value.tabs)
}
function isSessionTabsFrame(value: unknown): value is SessionTabsFrame {
if (!isRecord(value)) {
return false
}
return value.type === 'snapshots'
? Array.isArray(value.snapshots) && value.snapshots.every(isSessionTabsResult)
: value.type === 'updated' && isSessionTabsResult(value)
}
function displayed({
navigationIntent: _command,
retiredTerminalSurfaces: _proofs,
...workspace
}: RuntimeMobileSessionTabsResult): RemoteLayoutSnapshotWorkspace {
return workspace
}
// Which tabs exist and where; titles and status change far more often and can wait for a save.
function structure(workspace: RemoteLayoutSnapshotWorkspace | undefined): string {
return JSON.stringify([
workspace?.tabs.map((tab) => tab.id),
workspace?.tabGroups?.map((group) => [group.id, group.tabOrder]),
workspace?.tabGroupLayout
])
}
/** Called for every relayed stream frame; only the window's all-worktree tab stream is recorded. */
export function recordRemoteLayoutFrame(
store: RemoteLayoutSnapshotStore,
environmentId: string,
method: string,
response: RuntimeRpcResponse<unknown>
): void {
if (method !== 'session.tabs.subscribeAll' || !response.ok) {
return
}
const frame = response.result
if (!isSessionTabsFrame(frame)) {
return
}
const recorded = { receivedAt: Date.now(), serverRuntimeId: response._meta.runtimeId }
if (frame.type === 'snapshots') {
// A census replaces the whole entry: a worktree missing from it is gone.
const workspaces = Object.fromEntries(
frame.snapshots.map((snapshot) => [snapshot.worktree, displayed(snapshot)])
)
store.setRemoteLayoutSnapshot(environmentId, { ...recorded, workspaces }, 'now')
return
}
const { type: _type, removed, ...result } = frame
const { [frame.worktree]: previous, ...others } =
store.getRemoteLayoutSnapshot(environmentId)?.workspaces ?? {}
if (removed) {
store.setRemoteLayoutSnapshot(environmentId, { ...recorded, workspaces: others }, 'now')
return
}
const workspace = displayed(result)
store.setRemoteLayoutSnapshot(
environmentId,
{ ...recorded, workspaces: { ...others, [frame.worktree]: workspace } },
structure(previous) === structure(workspace) ? 'later' : 'now'
)
}
/** A removed server's snapshot goes with it. */
export function forgetRemoteLayoutSnapshot(
store: RemoteLayoutSnapshotStore,
environmentId: string
): void {
store.setRemoteLayoutSnapshot(environmentId, null, 'now')
}
@@ -110,9 +110,7 @@ export function registerRuntimeEnvironmentConnectivityHandlers({
)
}
const removed = removeEnvironment(getUserDataPath(), args.selector)
void retireRemovedRuntimeEnvironment(removed.id, invalidateTransport, (hostId) =>
store.removeWorkspaceSessionHost(hostId)
)
void retireRemovedRuntimeEnvironment(removed.id, invalidateTransport, store)
closeLegacySelectorTransport(args.selector, removed.id)
return { removed: redactRuntimeEnvironment(removed) }
}
@@ -10,9 +10,14 @@ vi.mock('../browser/browser-route-partition-storage-retirement', () => ({
const { retireRemovedRuntimeEnvironment } = await import('./runtime-environment-removal-cleanup')
describe('retiring a removed runtime environment', () => {
it('forgets the server’s workspace session partition under its runtime host id', async () => {
const forgetHostSession = vi.fn()
await retireRemovedRuntimeEnvironment('env 1', vi.fn(), forgetHostSession)
expect(forgetHostSession).toHaveBeenCalledWith('runtime:env%201')
it('forgets the server’s session partition and its layout snapshot', async () => {
const store = {
removeWorkspaceSessionHost: vi.fn(),
getRemoteLayoutSnapshot: vi.fn(),
setRemoteLayoutSnapshot: vi.fn()
}
await retireRemovedRuntimeEnvironment('env 1', vi.fn(), store)
expect(store.removeWorkspaceSessionHost).toHaveBeenCalledWith('runtime:env%201')
expect(store.setRemoteLayoutSnapshot).toHaveBeenCalledWith('env 1', null, 'now')
})
})
@@ -3,16 +3,27 @@ import { retireBrowserRoutePartitionStorageForEnvironment } from '../browser/bro
import { clearRuntimeEnvironmentCapabilityEvidence } from './runtime-environment-capability-evidence'
import { clearRuntimeEnvironmentManualDisconnect } from './runtime-environment-manual-disconnect'
import { toRuntimeExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
import {
forgetRemoteLayoutSnapshot,
type RemoteLayoutSnapshotStore
} from './remote-layout-snapshot-recorder'
/** What a removed server leaves in the profile. */
export type RemovedServerStore = RemoteLayoutSnapshotStore & {
/** Drops the unlinked server's workspace session partition. */
removeWorkspaceSessionHost: (hostId: ExecutionHostId) => void
}
/** Retires a removed server's client-side state; resolves once its transport is invalidated. */
export function retireRemovedRuntimeEnvironment(
environmentId: string,
invalidateTransport: (environmentId: string) => Promise<void> | void,
forgetHostSession?: (hostId: ExecutionHostId) => void
store: RemovedServerStore
): Promise<void> {
clearRuntimeEnvironmentCapabilityEvidence(environmentId)
// Why: listings enumerate session partitions as known hosts, so a kept one names a dead server.
forgetHostSession?.(toRuntimeExecutionHostId(environmentId))
store.removeWorkspaceSessionHost(toRuntimeExecutionHostId(environmentId))
forgetRemoteLayoutSnapshot(store, environmentId)
clearRuntimeEnvironmentManualDisconnect(environmentId)
const retiring = Promise.resolve(invalidateTransport(environmentId))
// Why: removal is an explicit lifecycle decision, so its client-hosted browser storage goes
@@ -48,7 +48,11 @@ describe('runtime environment removal storage clearing', () => {
clearStorageMock.mockResolvedValue({ clearedPartitions: ['persist:one'], livePartitions: [] })
registerRuntimeEnvironmentConnectivityHandlers({
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: remove reads only settings and the session-host prune.
store: { getSettings: () => ({}), removeWorkspaceSessionHost: vi.fn() } as never,
store: {
getSettings: () => ({}),
removeWorkspaceSessionHost: vi.fn(),
setRemoteLayoutSnapshot: vi.fn()
} as never,
getUserDataPath: () => '/tmp/orca-user-data',
invalidateTransport: () => teardown
})
@@ -69,7 +73,11 @@ describe('runtime environment removal storage clearing', () => {
.mockResolvedValueOnce({ clearedPartitions: ['persist:one'], livePartitions: [] })
registerRuntimeEnvironmentConnectivityHandlers({
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: remove reads only settings and the session-host prune.
store: { getSettings: () => ({}), removeWorkspaceSessionHost: vi.fn() } as never,
store: {
getSettings: () => ({}),
removeWorkspaceSessionHost: vi.fn(),
setRemoteLayoutSnapshot: vi.fn()
} as never,
getUserDataPath: () => '/tmp/orca-user-data',
invalidateTransport: () => Promise.resolve()
})
@@ -2,6 +2,7 @@ import { ipcMain } from 'electron'
import { randomUUID } from 'node:crypto'
import { resolveEnvironment } from '../../shared/runtime-environment-store'
import type { RemoteRuntimeSubscription } from '../../shared/remote-runtime-client'
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-connectivity-handlers'
import { getRuntimeEnvironmentTransportGeneration } from './runtime-environment-transport-generation'
import { subscribeRuntimeEnvironment } from './runtime-environment-transport-routing'
@@ -23,8 +24,14 @@ export function registerRuntimeEnvironmentSubscriptionHandlers(args: {
getUserDataPath: () => string
remoteRuntimeSubscriptions: Map<string, RetainedRemoteRuntimeSubscription>
pendingSubscriptions: Map<string, PendingRuntimeSubscription>
recordLayoutFrame: (
environmentId: string,
method: string,
response: RuntimeRpcResponse<unknown>
) => void
}): void {
const { getUserDataPath, remoteRuntimeSubscriptions, pendingSubscriptions } = args
const { getUserDataPath, remoteRuntimeSubscriptions, pendingSubscriptions, recordLayoutFrame } =
args
ipcMain.handle(
'runtimeEnvironments:subscribe',
async (
@@ -149,6 +156,10 @@ export function registerRuntimeEnvironmentSubscriptionHandlers(args: {
subscriptionId,
...payload
})
// After the send, so recording never delays or changes what the window gets.
if (payload.type === 'response') {
recordLayoutFrame(environment.id, args.method, payload.response)
}
}
},
onClose: () => {
@@ -46,7 +46,8 @@ beforeEach(() => {
registerRuntimeEnvironmentSubscriptionHandlers({
getUserDataPath: () => '/profile',
remoteRuntimeSubscriptions,
pendingSubscriptions
pendingSubscriptions,
recordLayoutFrame: vi.fn()
})
})
afterEach(() => {
@@ -0,0 +1,94 @@
import { beforeEach, expect, it, vi } from 'vitest'
import type { subscribeRuntimeEnvironment } from './runtime-environment-transport-routing'
const mocks = vi.hoisted(() => ({ handle: vi.fn(), on: vi.fn(), subscribe: vi.fn() }))
vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle, on: mocks.on } }))
vi.mock('../../shared/runtime-environment-store', () => ({
resolveEnvironment: () => ({ id: 'env', pairingRevision: 1, createdAt: 1 })
}))
vi.mock('./runtime-environment-transport-routing', () => ({
subscribeRuntimeEnvironment: mocks.subscribe
}))
import { advanceRuntimeEnvironmentTransportGeneration } from './runtime-environment-transport-generation'
import {
registerRuntimeEnvironmentSubscriptionHandlers,
type PendingRuntimeSubscription,
type RetainedRemoteRuntimeSubscription
} from './runtime-environment-subscription-handlers'
type Callbacks = Parameters<typeof subscribeRuntimeEnvironment>[5]
const sender = {
id: 1,
isDestroyed: () => false,
send: vi.fn(),
once: vi.fn(),
removeListener: vi.fn()
}
const recordLayoutFrame = vi.fn()
const census = {
id: 'stream',
ok: true as const,
result: { type: 'snapshots', snapshots: [] },
_meta: { runtimeId: 'server' }
}
async function open(method: string): Promise<Callbacks> {
let callbacks: Callbacks | undefined
mocks.subscribe.mockImplementationOnce(
async (...parameters: Parameters<typeof subscribeRuntimeEnvironment>) => {
callbacks = parameters[5]
return { requestId: 'request', sendBinary: vi.fn(() => true), close: vi.fn() }
}
)
const subscribe = mocks.handle.mock.calls.find(
([name]) => name === 'runtimeEnvironments:subscribe'
)![1]
await subscribe({ sender }, { selector: 'env', method, subscriptionId: method })
return callbacks!
}
beforeEach(() => {
vi.clearAllMocks()
mocks.subscribe.mockReset()
registerRuntimeEnvironmentSubscriptionHandlers({
getUserDataPath: () => '/profile',
remoteRuntimeSubscriptions: new Map<string, RetainedRemoteRuntimeSubscription>(),
pendingSubscriptions: new Map<string, PendingRuntimeSubscription>(),
recordLayoutFrame
})
})
it('records the window’s own stream after forwarding it unchanged, opening nothing new', async () => {
const callbacks = await open('session.tabs.subscribeAll')
callbacks.onEvent({ type: 'response', response: census })
// The window's subscription is the only request the server sees.
expect(mocks.subscribe).toHaveBeenCalledOnce()
expect(mocks.subscribe.mock.calls[0]?.[2]).toBe('session.tabs.subscribeAll')
expect(sender.send).toHaveBeenCalledExactlyOnceWith('runtimeEnvironments:subscriptionEvent', {
subscriptionId: 'session.tabs.subscribeAll',
type: 'response',
response: census
})
expect(recordLayoutFrame).toHaveBeenCalledExactlyOnceWith(
'env',
'session.tabs.subscribeAll',
census
)
expect(sender.send.mock.invocationCallOrder[0]).toBeLessThan(
recordLayoutFrame.mock.invocationCallOrder[0]!
)
})
it('offers the recorder only frames the window was sent', async () => {
const callbacks = await open('session.tabs.subscribeAll')
callbacks.onEvent({ type: 'binary', bytes: new Uint8Array([1]) })
callbacks.onEvent({ type: 'error', code: 'lost', message: 'lost' })
// A replaced transport's late frame reaches neither the window nor the snapshot.
advanceRuntimeEnvironmentTransportGeneration('env')
sender.send.mockClear()
callbacks.onEvent({ type: 'response', response: census })
expect(sender.send).not.toHaveBeenCalled()
expect(recordLayoutFrame).not.toHaveBeenCalled()
})
@@ -83,6 +83,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
getSettings: () => { activeRuntimeEnvironmentId: string | null }
updateSettings: ReturnType<typeof vi.fn>
removeWorkspaceSessionHost: ReturnType<typeof vi.fn>
setRemoteLayoutSnapshot: ReturnType<typeof vi.fn>
}
beforeEach(() => {
@@ -91,6 +92,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
store = {
getSettings: () => ({ activeRuntimeEnvironmentId }),
removeWorkspaceSessionHost: vi.fn(),
setRemoteLayoutSnapshot: vi.fn(),
updateSettings: vi.fn((updates: { activeRuntimeEnvironmentId: string | null }) => {
activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId
})
@@ -101,6 +101,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
getSettings: () => { activeRuntimeEnvironmentId: string | null }
updateSettings: ReturnType<typeof vi.fn>
removeWorkspaceSessionHost: ReturnType<typeof vi.fn>
setRemoteLayoutSnapshot: ReturnType<typeof vi.fn>
}
beforeEach(() => {
@@ -109,6 +110,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
store = {
getSettings: () => ({ activeRuntimeEnvironmentId }),
removeWorkspaceSessionHost: vi.fn(),
setRemoteLayoutSnapshot: vi.fn(),
updateSettings: vi.fn((updates: { activeRuntimeEnvironmentId: string | null }) => {
activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId
})
@@ -285,10 +287,11 @@ describe('registerRuntimeEnvironmentHandlers', () => {
})
expect(activeRuntimeEnvironmentId).toBeNull()
expect(closeRemoteRuntimeRequestConnectionMock).toHaveBeenCalledWith(added.environment.id)
// A removed server's session partition goes too, so listings stop naming it as a host.
// A removed server's session partition and layout snapshot go too.
expect(store.removeWorkspaceSessionHost).toHaveBeenCalledWith(
`runtime:${encodeURIComponent(added.environment.id)}`
)
expect(store.setRemoteLayoutSnapshot).toHaveBeenCalledWith(added.environment.id, null, 'now')
expect(JSON.stringify(removed)).not.toContain('device-token')
expect(await list(null, undefined)).toEqual([])
})
@@ -91,6 +91,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
getSettings: () => { activeRuntimeEnvironmentId: string | null }
updateSettings: ReturnType<typeof vi.fn>
removeWorkspaceSessionHost: ReturnType<typeof vi.fn>
setRemoteLayoutSnapshot: ReturnType<typeof vi.fn>
}
beforeEach(() => {
@@ -99,6 +100,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
store = {
getSettings: () => ({ activeRuntimeEnvironmentId }),
removeWorkspaceSessionHost: vi.fn(),
setRemoteLayoutSnapshot: vi.fn(),
updateSettings: vi.fn((updates: { activeRuntimeEnvironmentId: string | null }) => {
activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId
})
+5 -2
View File
@@ -6,6 +6,7 @@ import {
import { app, ipcMain } from 'electron'
import { listEnvironments } from '../../shared/runtime-environment-store'
import type { Store } from '../persistence'
import { recordRemoteLayoutFrame } from './remote-layout-snapshot-recorder'
import {
isRuntimeEnvironmentManuallyDisconnected,
registerRuntimeEnvironmentConnectivityHandlers,
@@ -147,11 +148,13 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void {
invalidateTransport: invalidateRuntimeEnvironmentTransport,
clearHostServerStatus: clearPublishedManagedServer,
clearHostServerNotes: clearManagedServerNotes,
forgetHostSession: (hostId) => store.removeWorkspaceSessionHost(hostId)
store
})
registerRuntimeEnvironmentSubscriptionHandlers({
getUserDataPath,
remoteRuntimeSubscriptions,
pendingSubscriptions
pendingSubscriptions,
recordLayoutFrame: (environmentId, method, response) =>
recordRemoteLayoutFrame(store, environmentId, method, response)
})
}
@@ -0,0 +1,148 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { getDefaultPersistedState } from '../../../shared/constants'
import { setSecretStore } from '../../../shared/secret-store'
import { recordRemoteLayoutFrame } from '../../ipc/remote-layout-snapshot-recorder'
import { openProfileStateDatabaseReadOnly } from '../profile-state/profile-state-database'
import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority'
import { Store } from './store'
vi.mock('electron', () => ({
app: {
getPath: () => tmpdir(),
getName: () => 'orca-test',
getVersion: () => '0.0.0-test',
isPackaged: false,
on: () => {},
whenReady: () => Promise.resolve()
},
ipcMain: { on: () => {}, handle: () => {} },
BrowserWindow: { getAllWindows: () => [] }
}))
vi.mock('../../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../../ssh/ssh-config-parser', () => ({
loadUserSshConfig: () => ({ hosts: [] }),
sshConfigHostsToTargets: () => []
}))
const directories: string[] = []
const stores: Store[] = []
beforeEach(() => {
setSecretStore({
isEncryptionAvailable: () => true,
encryptString: (plaintext) => Buffer.from(plaintext),
decryptString: (ciphertext) => ciphertext.toString(),
describeProtectionGap: () => null
})
vi.spyOn(ProfileStateSqliteAuthority.prototype, 'scheduleBackup').mockImplementation(() => {})
})
afterEach(async () => {
for (const store of stores.splice(0)) {
store.freezeWrites()
await store.flushAsync()
}
for (const directory of directories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
}
vi.restoreAllMocks()
})
function profile() {
const directory = mkdtempSync(join(tmpdir(), 'orca-remote-layout-snapshot-'))
directories.push(directory)
const databasePath = join(directory, 'profile-state.db')
const profileId = 'remote-layout-snapshot'
new ProfileStateSqliteAuthority(databasePath, profileId).writeSerializedState(
Buffer.from(JSON.stringify(getDefaultPersistedState(directory)))
)
const open = () => {
const store = new Store({
dataFile: join(directory, 'orca-data.json'),
profileStateAuthority: new ProfileStateSqliteAuthority(databasePath, profileId)
})
stores.push(store)
return store
}
const documentRevisions = () => {
const opened = openProfileStateDatabaseReadOnly(databasePath, profileId)
try {
return new Map(
opened.db
.prepare('SELECT domain, revision FROM profile_state_documents')
.all()
.map((row) => [String(row.domain), Number(row.revision)])
)
} finally {
opened.db.close()
}
}
return { open, documentRevisions }
}
const workspace = {
worktree: 'repo::/srv/repo',
publicationEpoch: 'epoch',
snapshotVersion: 3,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: []
}
it('survives a restart and saves only its own domain', () => {
const { open, documentRevisions } = profile()
const store = open()
store.flushOrThrow()
const before = documentRevisions()
const fullWrite = vi.spyOn(
ProfileStateSqliteAuthority.prototype,
'writeCompleteSerializedDomains'
)
recordRemoteLayoutFrame(store, 'env', 'session.tabs.subscribeAll', {
id: 'stream',
ok: true,
result: { type: 'snapshots', snapshots: [workspace] },
_meta: { runtimeId: 'server' }
})
store.flushOrThrow()
// Not a whole-profile serialization per recorded frame.
expect(fullWrite).not.toHaveBeenCalled()
const after = documentRevisions()
const changed = [...after].filter(([domain, revision]) => before.get(domain) !== revision)
expect(changed.map(([domain]) => domain)).toEqual(['remoteLayoutSnapshots'])
expect(open().getRemoteLayoutSnapshot('env')).toMatchObject({
serverRuntimeId: 'server',
workspaces: { [workspace.worktree]: workspace }
})
})
it('keeps a title-only change without arming a save, and writes it with the next save', () => {
const { open } = profile()
const store = open()
const record = (result: Record<string, unknown>) =>
recordRemoteLayoutFrame(store, 'env', 'session.tabs.subscribeAll', {
id: 'stream',
ok: true,
result,
_meta: { runtimeId: 'server' }
})
record({ type: 'snapshots', snapshots: [workspace] })
store.flushOrThrow()
const timers = vi.spyOn(globalThis, 'setTimeout')
const retitled = { ...workspace, snapshotVersion: 4 }
record({ type: 'updated', ...retitled })
expect(timers).not.toHaveBeenCalled()
timers.mockRestore()
// Another domain's save writes only the domains marked dirty, which must include this one.
store.updateSettings({ opencodeSessionCookie: 'cookie' })
store.flushOrThrow()
expect(open().getRemoteLayoutSnapshot('env')?.workspaces[workspace.worktree]).toEqual(retitled)
})
@@ -0,0 +1,51 @@
import type { RemoteLayoutSnapshot } from '../../../shared/remote-layout-snapshot'
import type { StoreRuntimeState } from './store-runtime-state'
import type { WriteSchedulingOperations } from './write-scheduling'
import { scheduleSave } from './write-scheduling'
type RemoteLayoutSnapshotRuntime = Pick<StoreRuntimeState, 'state' | 'dirtyProfileStateDomains'>
const remoteLayoutSnapshotPersistenceContext = Symbol('RemoteLayoutSnapshotPersistence')
type RemoteLayoutSnapshotPersistenceContext = {
runtime: RemoteLayoutSnapshotRuntime
scheduling: WriteSchedulingOperations
}
export class RemoteLayoutSnapshotPersistence {
readonly [remoteLayoutSnapshotPersistenceContext]: RemoteLayoutSnapshotPersistenceContext
constructor(runtime: RemoteLayoutSnapshotRuntime, scheduling: WriteSchedulingOperations) {
this[remoteLayoutSnapshotPersistenceContext] = { runtime, scheduling }
}
getRemoteLayoutSnapshot(environmentId: string): RemoteLayoutSnapshot | undefined {
return this[remoteLayoutSnapshotPersistenceContext].runtime.state.remoteLayoutSnapshots?.[
environmentId
]
}
/**
* Only the recorder calls this (ratchet in remote-layout-snapshot-recorder.test.ts). `later`
* marks the domain dirty without arming the save timer, so it rides the next save or quit flush.
*/
setRemoteLayoutSnapshot(
environmentId: string,
snapshot: RemoteLayoutSnapshot | null,
save: 'now' | 'later'
): void {
const { runtime, scheduling } = this[remoteLayoutSnapshotPersistenceContext]
const { [environmentId]: previous, ...others } = runtime.state.remoteLayoutSnapshots ?? {}
if (snapshot === null && previous === undefined) {
return
}
runtime.state.remoteLayoutSnapshots = snapshot
? { ...others, [environmentId]: snapshot }
: others
if (save === 'later') {
runtime.dirtyProfileStateDomains?.add('remoteLayoutSnapshots')
return
}
// Named so the save writes only this domain and wakes no workspace-session listener.
scheduleSave(scheduling, ['remoteLayoutSnapshots'])
}
}
@@ -108,6 +108,7 @@ export class StateSerializationSecretHandlingOperations {
}
break
case 'sshRemotePtyLeases':
case 'remoteLayoutSnapshots':
case 'legacyPaneKeyAliasEntries':
case 'migrationUnsupportedPtyEntries':
stateToSave[domain] = this.runtime.state[domain]
@@ -16,6 +16,7 @@ import { MetadataLineageOperations } from './metadata-lineage-operations'
import { ProjectCollectionOperations } from './project-collection-operations'
import { AutomationPersistence } from './automation-persistence'
import { MobileTabSelectionPersistence } from './mobile-tab-selection-persistence'
import { RemoteLayoutSnapshotPersistence } from './remote-layout-snapshot-persistence'
import { SparsePresetPersistence } from './sparse-preset-persistence'
import { PtyBindingPersistenceOperations } from './pty-binding-persistence'
import { SshProfileOperations } from './ssh-profile-operations'
@@ -29,6 +30,7 @@ export type StoreDomainOperations = WriteSchedulingOperations &
ProjectCollectionOperations &
RepoLifecycleOperations &
MobileTabSelectionPersistence &
RemoteLayoutSnapshotPersistence &
SparsePresetPersistence &
AutomationPersistence &
MetadataLineageOperations &
@@ -60,6 +62,7 @@ export type StoreDomains = {
projects: ProjectCollectionOperations
automations: AutomationPersistence
mobileTabSelections: MobileTabSelectionPersistence
remoteLayoutSnapshots: RemoteLayoutSnapshotPersistence
sparsePresets: SparsePresetPersistence
ptyBindings: PtyBindingPersistenceOperations
sshProfiles: SshProfileOperations
@@ -75,6 +78,7 @@ export const STORE_DOMAIN_OPERATION_CLASSES = [
ProjectCollectionOperations,
RepoLifecycleOperations,
MobileTabSelectionPersistence,
RemoteLayoutSnapshotPersistence,
SparsePresetPersistence,
AutomationPersistence,
MetadataLineageOperations,
@@ -122,6 +126,7 @@ export function createStoreDomains(runtime: StoreRuntimeState): StoreDomains {
const projects = new ProjectCollectionOperations(runtime, repos, scheduling, metadata)
const automations = new AutomationPersistence(runtime, flushBarriers, preferences)
const mobileTabSelections = new MobileTabSelectionPersistence(runtime, scheduling)
const remoteLayoutSnapshots = new RemoteLayoutSnapshotPersistence(runtime, scheduling)
const sparsePresets = new SparsePresetPersistence(runtime, scheduling)
const ptyBindings = new PtyBindingPersistenceOperations(runtime, sessions)
const sshProfiles = new SshProfileOperations(runtime, scheduling, repos)
@@ -150,6 +155,7 @@ export function createStoreDomains(runtime: StoreRuntimeState): StoreDomains {
projects,
automations,
mobileTabSelections,
remoteLayoutSnapshots,
sparsePresets,
ptyBindings,
sshProfiles,
+5
View File
@@ -17,6 +17,7 @@ import type { IssueInfo, PRInfo } from './github/pull-request-types'
import type { OnboardingState } from './onboarding-state-types'
import type { PersistedUIState } from './persisted-ui-state-types'
import type { ProjectGroup } from './project-group-types'
import type { RemoteLayoutSnapshot } from './remote-layout-snapshot'
import type { Project, ProjectHostSetup } from './project-types'
import type { Repo } from './repo-types'
import type { SparsePreset } from './worktree/create-types'
@@ -99,6 +100,10 @@ export type PersistedState = {
workspaceSession: WorkspaceSessionState
/** Per-execution-host session partitions for non-'local' hosts (ssh:/runtime:); 'local' stays in workspaceSession so pre-partition builds keep working. */
workspaceSessionsByHostId?: Partial<Record<ExecutionHostId, WorkspaceSessionState>>
/** Display-only snapshot of each paired server's last layout, keyed by environment id. Written
* only by the remote layout snapshot recorder; a separate domain because older builds write
* the `runtime:<env>` partitions. */
remoteLayoutSnapshots?: Record<string, RemoteLayoutSnapshot>
sshTargets: SshTarget[]
/** Highest SSH target registration generation issued; prevents identity reuse after rollback. */
sshTargetGenerationCounter?: number
+18
View File
@@ -0,0 +1,18 @@
import type { RuntimeMobileSessionTabsResult } from './runtime-session-contracts'
/** A workspace as last drawn: one-shot commands and retirement proofs (sent as deltas) are not kept. */
export type RemoteLayoutSnapshotWorkspace = Omit<
RuntimeMobileSessionTabsResult,
'navigationIntent' | 'retiredTerminalSurfaces'
>
/**
* The last layout one paired server sent this desktop (design 6.3, D3), kept so a restart while
* the server is offline can still draw its tabs. Display-only: never edited, never sent back.
*/
export type RemoteLayoutSnapshot = {
receivedAt: number
serverRuntimeId: string
/** Keyed by worktree id, each from the server's last `session.tabs` result for it. */
workspaces: Record<string, RemoteLayoutSnapshotWorkspace>
}