mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
Reference in New Issue
Block a user