From 666d990bc015c704c85c78b578cb8aa52ed64ffa Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:36:37 -0400 Subject: [PATCH] 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. --- src/main/ipc/managed-orcad-actions.ts | 16 +- ...orcad-runtime-maintenance-handlers.test.ts | 10 +- .../remote-layout-snapshot-recorder.test.ts | 194 ++++++++++++++++++ .../ipc/remote-layout-snapshot-recorder.ts | 104 ++++++++++ ...ntime-environment-connectivity-handlers.ts | 4 +- ...untime-environment-removal-cleanup.test.ts | 13 +- .../runtime-environment-removal-cleanup.ts | 15 +- ...untime-environment-removal-storage.test.ts | 12 +- ...ntime-environment-subscription-handlers.ts | 13 +- ...-environment-subscription-identity.test.ts | 3 +- ...ment-subscription-layout-recording.test.ts | 94 +++++++++ ...time-environments-capability-cache.test.ts | 2 + .../ipc/runtime-environments-pairing.test.ts | 5 +- ...environments-subscription-teardown.test.ts | 2 + src/main/ipc/runtime-environments.ts | 7 +- ...remote-layout-snapshot-persistence.test.ts | 148 +++++++++++++ .../remote-layout-snapshot-persistence.ts | 51 +++++ .../state-serialization-secret-handling.ts | 1 + .../loading-store/store-domain-composition.ts | 6 + src/shared/persisted-state-types.ts | 5 + src/shared/remote-layout-snapshot.ts | 18 ++ 21 files changed, 695 insertions(+), 28 deletions(-) create mode 100644 src/main/ipc/remote-layout-snapshot-recorder.test.ts create mode 100644 src/main/ipc/remote-layout-snapshot-recorder.ts create mode 100644 src/main/ipc/runtime-environment-subscription-layout-recording.test.ts create mode 100644 src/main/persistence/loading-store/remote-layout-snapshot-persistence.test.ts create mode 100644 src/main/persistence/loading-store/remote-layout-snapshot-persistence.ts create mode 100644 src/shared/remote-layout-snapshot.ts diff --git a/src/main/ipc/managed-orcad-actions.ts b/src/main/ipc/managed-orcad-actions.ts index a356605f10c..f6e2dd8c513 100644 --- a/src/main/ipc/managed-orcad-actions.ts +++ b/src/main/ipc/managed-orcad-actions.ts @@ -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), diff --git a/src/main/ipc/orcad-runtime-maintenance-handlers.test.ts b/src/main/ipc/orcad-runtime-maintenance-handlers.test.ts index 223be800070..2baa16e2295 100644 --- a/src/main/ipc/orcad-runtime-maintenance-handlers.test.ts +++ b/src/main/ipc/orcad-runtime-maintenance-handlers.test.ts @@ -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 { 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') }) diff --git a/src/main/ipc/remote-layout-snapshot-recorder.test.ts b/src/main/ipc/remote-layout-snapshot-recorder.test.ts new file mode 100644 index 00000000000..6f82cdd6f77 --- /dev/null +++ b/src/main/ipc/remote-layout-snapshot-recorder.test.ts @@ -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() + 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 { + return { id: 'stream', ok: true, result, _meta: { runtimeId } } +} + +function workspacesOf(snapshots: Map, 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) => 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' + ]) + }) +}) diff --git a/src/main/ipc/remote-layout-snapshot-recorder.ts b/src/main/ipc/remote-layout-snapshot-recorder.ts new file mode 100644 index 00000000000..de115a9871e --- /dev/null +++ b/src/main/ipc/remote-layout-snapshot-recorder.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 { + 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 +): 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') +} diff --git a/src/main/ipc/runtime-environment-connectivity-handlers.ts b/src/main/ipc/runtime-environment-connectivity-handlers.ts index 1b23ccde648..7a3b88850a2 100644 --- a/src/main/ipc/runtime-environment-connectivity-handlers.ts +++ b/src/main/ipc/runtime-environment-connectivity-handlers.ts @@ -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) } } diff --git a/src/main/ipc/runtime-environment-removal-cleanup.test.ts b/src/main/ipc/runtime-environment-removal-cleanup.test.ts index 261a24f652d..d3f40eeee23 100644 --- a/src/main/ipc/runtime-environment-removal-cleanup.test.ts +++ b/src/main/ipc/runtime-environment-removal-cleanup.test.ts @@ -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') }) }) diff --git a/src/main/ipc/runtime-environment-removal-cleanup.ts b/src/main/ipc/runtime-environment-removal-cleanup.ts index c1961c137d9..2e67142824c 100644 --- a/src/main/ipc/runtime-environment-removal-cleanup.ts +++ b/src/main/ipc/runtime-environment-removal-cleanup.ts @@ -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, - forgetHostSession?: (hostId: ExecutionHostId) => void + store: RemovedServerStore ): Promise { 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 diff --git a/src/main/ipc/runtime-environment-removal-storage.test.ts b/src/main/ipc/runtime-environment-removal-storage.test.ts index 012df15f47a..f21d1ac5e29 100644 --- a/src/main/ipc/runtime-environment-removal-storage.test.ts +++ b/src/main/ipc/runtime-environment-removal-storage.test.ts @@ -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() }) diff --git a/src/main/ipc/runtime-environment-subscription-handlers.ts b/src/main/ipc/runtime-environment-subscription-handlers.ts index 0ca81f8bf92..a579b9c6546 100644 --- a/src/main/ipc/runtime-environment-subscription-handlers.ts +++ b/src/main/ipc/runtime-environment-subscription-handlers.ts @@ -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 pendingSubscriptions: Map + recordLayoutFrame: ( + environmentId: string, + method: string, + response: RuntimeRpcResponse + ) => 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: () => { diff --git a/src/main/ipc/runtime-environment-subscription-identity.test.ts b/src/main/ipc/runtime-environment-subscription-identity.test.ts index 8712b5f1cc3..3f106f8e1f3 100644 --- a/src/main/ipc/runtime-environment-subscription-identity.test.ts +++ b/src/main/ipc/runtime-environment-subscription-identity.test.ts @@ -46,7 +46,8 @@ beforeEach(() => { registerRuntimeEnvironmentSubscriptionHandlers({ getUserDataPath: () => '/profile', remoteRuntimeSubscriptions, - pendingSubscriptions + pendingSubscriptions, + recordLayoutFrame: vi.fn() }) }) afterEach(() => { diff --git a/src/main/ipc/runtime-environment-subscription-layout-recording.test.ts b/src/main/ipc/runtime-environment-subscription-layout-recording.test.ts new file mode 100644 index 00000000000..de5c9255bce --- /dev/null +++ b/src/main/ipc/runtime-environment-subscription-layout-recording.test.ts @@ -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[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 { + let callbacks: Callbacks | undefined + mocks.subscribe.mockImplementationOnce( + async (...parameters: Parameters) => { + 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(), + pendingSubscriptions: new Map(), + 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() +}) diff --git a/src/main/ipc/runtime-environments-capability-cache.test.ts b/src/main/ipc/runtime-environments-capability-cache.test.ts index 695eb511bcb..3746780476a 100644 --- a/src/main/ipc/runtime-environments-capability-cache.test.ts +++ b/src/main/ipc/runtime-environments-capability-cache.test.ts @@ -83,6 +83,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { getSettings: () => { activeRuntimeEnvironmentId: string | null } updateSettings: ReturnType removeWorkspaceSessionHost: ReturnType + setRemoteLayoutSnapshot: ReturnType } 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 }) diff --git a/src/main/ipc/runtime-environments-pairing.test.ts b/src/main/ipc/runtime-environments-pairing.test.ts index c435a05ab81..3f5eb01e8d6 100644 --- a/src/main/ipc/runtime-environments-pairing.test.ts +++ b/src/main/ipc/runtime-environments-pairing.test.ts @@ -101,6 +101,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { getSettings: () => { activeRuntimeEnvironmentId: string | null } updateSettings: ReturnType removeWorkspaceSessionHost: ReturnType + setRemoteLayoutSnapshot: ReturnType } 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([]) }) diff --git a/src/main/ipc/runtime-environments-subscription-teardown.test.ts b/src/main/ipc/runtime-environments-subscription-teardown.test.ts index e3005c2b6fe..49736d30007 100644 --- a/src/main/ipc/runtime-environments-subscription-teardown.test.ts +++ b/src/main/ipc/runtime-environments-subscription-teardown.test.ts @@ -91,6 +91,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { getSettings: () => { activeRuntimeEnvironmentId: string | null } updateSettings: ReturnType removeWorkspaceSessionHost: ReturnType + setRemoteLayoutSnapshot: ReturnType } 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 }) diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts index ab3346a6dde..20c53748643 100644 --- a/src/main/ipc/runtime-environments.ts +++ b/src/main/ipc/runtime-environments.ts @@ -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) }) } diff --git a/src/main/persistence/loading-store/remote-layout-snapshot-persistence.test.ts b/src/main/persistence/loading-store/remote-layout-snapshot-persistence.test.ts new file mode 100644 index 00000000000..8acf3c96ad0 --- /dev/null +++ b/src/main/persistence/loading-store/remote-layout-snapshot-persistence.test.ts @@ -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) => + 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) +}) diff --git a/src/main/persistence/loading-store/remote-layout-snapshot-persistence.ts b/src/main/persistence/loading-store/remote-layout-snapshot-persistence.ts new file mode 100644 index 00000000000..31efc87f666 --- /dev/null +++ b/src/main/persistence/loading-store/remote-layout-snapshot-persistence.ts @@ -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 + +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']) + } +} diff --git a/src/main/persistence/loading-store/state-serialization-secret-handling.ts b/src/main/persistence/loading-store/state-serialization-secret-handling.ts index df410122968..d5c260517b3 100644 --- a/src/main/persistence/loading-store/state-serialization-secret-handling.ts +++ b/src/main/persistence/loading-store/state-serialization-secret-handling.ts @@ -108,6 +108,7 @@ export class StateSerializationSecretHandlingOperations { } break case 'sshRemotePtyLeases': + case 'remoteLayoutSnapshots': case 'legacyPaneKeyAliasEntries': case 'migrationUnsupportedPtyEntries': stateToSave[domain] = this.runtime.state[domain] diff --git a/src/main/persistence/loading-store/store-domain-composition.ts b/src/main/persistence/loading-store/store-domain-composition.ts index 2d4fb6e92d8..f6cbe9d7a83 100644 --- a/src/main/persistence/loading-store/store-domain-composition.ts +++ b/src/main/persistence/loading-store/store-domain-composition.ts @@ -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, diff --git a/src/shared/persisted-state-types.ts b/src/shared/persisted-state-types.ts index 19966bb2c3f..936f67c1222 100644 --- a/src/shared/persisted-state-types.ts +++ b/src/shared/persisted-state-types.ts @@ -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> + /** 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:` partitions. */ + remoteLayoutSnapshots?: Record sshTargets: SshTarget[] /** Highest SSH target registration generation issued; prevents identity reuse after rollback. */ sshTargetGenerationCounter?: number diff --git a/src/shared/remote-layout-snapshot.ts b/src/shared/remote-layout-snapshot.ts new file mode 100644 index 00000000000..9d610113128 --- /dev/null +++ b/src/shared/remote-layout-snapshot.ts @@ -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 +}