From b8a5c8f91046d869b27caf1c3ebaea085ff51f7b Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:09:35 -0400 Subject: [PATCH] A paired desktop draws a layout-owner server's workspaces from its layout stream (switch-on builds only) (#31181) * review: shorten a test comment; the methods file header names layout.apply Co-Authored-By: Claude * wip(h5b): a lost remote layout stream reopens with the local backoff * wip(h5b): server frame id translation (pending the binding handle) * wip(h5b): frame syncs take a source; the mirror gates frame-drawn server workspaces and indexes handles; ids translate with session.tabs handles * wip(h5b): one layout stream per capable paired server, handing workspaces between frames and the mirror * wip(h5b): tests for server frames; layout.getRemoteSnapshots returns only paired servers (L18) * wip(h5b): re-gate a server workspace when its routing arrives after the snapshot; e2e hook and spec * wip(h5b): late-owner test; e2e spec on the switch-on leg * wip(h5b): server streams follow every paired capable server, not the focused one; a known-kind guard without a cast * wip(h5b): server-frames tests read real paired-server state; no casts * fix(h5b): a handed-back workspace is the mirror's alone; frozen frames draw only gated workspaces * fix(h5b): a workspace rejoining the gate takes the full build --------- Co-authored-by: E2E Test Co-authored-by: Claude --- .github/workflows/layout-owner-e2e.yml | 2 +- .../workspace-layout-paired-desktop.test.ts | 2 +- .../rpc/methods/workspace-layout.test.ts | 12 + .../runtime/rpc/methods/workspace-layout.ts | 16 +- ...drated-server-layout-frames-switch.test.ts | 47 ++ ...reconcile-hydrated-workspace-tab-models.ts | 4 +- src/renderer/src/env.d.ts | 2 + .../src/runtime/layout-frame-stream.ts | 68 +++ .../src/runtime/runtime-layout-client.test.ts | 63 +-- .../src/runtime/runtime-layout-client.ts | 25 +- .../runtime/server-layout-frame-ids.test.ts | 243 ++++++++++ .../src/runtime/server-layout-frame-ids.ts | 297 ++++++++++++ .../server-layout-frames-handover.test.ts | 368 ++++++++++++++ .../server-layout-frames.test-fixture.ts | 303 ++++++++++++ .../src/runtime/server-layout-frames.test.ts | 385 +++++++++++++++ .../src/runtime/server-layout-frames.ts | 231 +++++++++ .../runtime/web-runtime-session-snapshot.ts | 4 +- .../active-session-subscription.ts | 11 +- .../web-session-tabs-sync/apply-snapshot.ts | 28 +- .../frame-drawn-server-workspaces.test.ts | 292 +++++++++++ .../frame-drawn-server-workspaces.ts | 51 ++ .../frame-drawn-workspace-status-patch.ts | 115 +++++ .../global-session-events.ts | 4 +- .../global-session-inventory-event.ts | 4 +- .../initial-terminal-seeder.ts | 6 + .../web-session-tabs-sync/load-initial.ts | 4 +- .../mirror-terminal-orphan-recovery.ts | 16 + .../mirrored-terminal-handle-index.ts | 150 ++++++ .../tracking-lifecycle.ts | 7 + ...workspace-layout-frame-drawn-workspaces.ts | 4 +- .../runtime/workspace-layout-frame-sync.ts | 83 ++-- ...aired-desktop-server-layout-frames.spec.ts | 452 ++++++++++++++++++ 32 files changed, 3174 insertions(+), 125 deletions(-) create mode 100644 src/renderer/src/app-shell/reconcile-hydrated-server-layout-frames-switch.test.ts create mode 100644 src/renderer/src/runtime/layout-frame-stream.ts create mode 100644 src/renderer/src/runtime/server-layout-frame-ids.test.ts create mode 100644 src/renderer/src/runtime/server-layout-frame-ids.ts create mode 100644 src/renderer/src/runtime/server-layout-frames-handover.test.ts create mode 100644 src/renderer/src/runtime/server-layout-frames.test-fixture.ts create mode 100644 src/renderer/src/runtime/server-layout-frames.test.ts create mode 100644 src/renderer/src/runtime/server-layout-frames.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/frame-drawn-server-workspaces.test.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/frame-drawn-server-workspaces.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/frame-drawn-workspace-status-patch.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/mirror-terminal-orphan-recovery.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/mirrored-terminal-handle-index.ts create mode 100644 tests/e2e/paired-desktop-server-layout-frames.spec.ts diff --git a/.github/workflows/layout-owner-e2e.yml b/.github/workflows/layout-owner-e2e.yml index 4bafb59ac82..3547a6cff80 100644 --- a/.github/workflows/layout-owner-e2e.yml +++ b/.github/workflows/layout-owner-e2e.yml @@ -25,7 +25,7 @@ jobs: uses: ./.github/workflows/e2e.yml with: ref: ${{ inputs.ref || github.sha }} - test_files: '["tests/e2e/workspace-layout-oracle-headless.spec.ts","tests/e2e/workspace-layout-oracle.spec.ts","tests/e2e/cli-file-open-position.spec.ts","tests/e2e/relaunch-first-terminal.spec.ts","tests/e2e/relaunch-sleeping-agents-resume-on-view.spec.ts","tests/e2e/terminal-cold-activation-deferral.spec.ts","tests/e2e/cli-first-worktree-single-terminal.spec.ts","tests/e2e/unopened-workspace-agent-single-resume.spec.ts","tests/e2e/terminal-pane-close-layout-consistency.spec.ts","tests/e2e/golden-file-open-edit-save.spec.ts","tests/e2e/golden-source-control-open-diff.spec.ts","tests/e2e/paired-cli-terminal-graph-sync-tab-retention.spec.ts","tests/e2e/floating-workspace-pane-height.spec.ts","tests/e2e/editor-model-host-isolation.spec.ts","tests/e2e/editor-tab-selection-restore.spec.ts","tests/e2e/finished-agent-ghost-resume.spec.ts","tests/e2e/live-background-terminal-mount-authority.spec.ts","tests/e2e/workspace-back-forward-navigation.spec.ts","tests/e2e/worktree-lineage-toggle-shortcut.spec.ts","tests/e2e/local-cli-terminal-retention.spec.ts","tests/e2e/default-tabs-mark-crash-relaunch.spec.ts","tests/e2e/terminal-cli-split-closed-before-mount.spec.ts","tests/e2e/layout-stream-loss-reopen.spec.ts","tests/e2e/terminal-tab-close-restart-persistence.spec.ts","tests/e2e/artificial-opencode-terminal-load.spec.ts","tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts","tests/e2e/headless-paired-remote-terminal-reveal-grid.spec.ts","tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts","tests/e2e/headless-paired-remote-terminal-viewer-size.spec.ts","tests/e2e/headless-serve-browser-tab-restart.spec.ts","tests/e2e/headless-serve-cli-terminal-retention-parity.spec.ts","tests/e2e/headless-serve-desktop-activation.spec.ts","tests/e2e/headless-serve-focused-terminal-create.spec.ts","tests/e2e/headless-serve-mobile-chat-tab-order.spec.ts","tests/e2e/headless-serve-mobile-native-chat-identity.spec.ts","tests/e2e/headless-serve-session-history-naming-requests.spec.ts","tests/e2e/layout-compat-old-phone-on-new-server.spec.ts","tests/e2e/layout-compat-serve-restart-keeps-at-at-terminals.spec.ts","tests/e2e/paired-mobile-serve-restart-terminal-tabs.spec.ts","tests/e2e/paired-mobile-desktop-tab-composition.spec.ts","tests/e2e/paired-remote-terminal-serve-restart-binding.spec.ts","tests/e2e/ssh-orcad-"]' + test_files: '["tests/e2e/workspace-layout-oracle-headless.spec.ts","tests/e2e/workspace-layout-oracle.spec.ts","tests/e2e/cli-file-open-position.spec.ts","tests/e2e/relaunch-first-terminal.spec.ts","tests/e2e/relaunch-sleeping-agents-resume-on-view.spec.ts","tests/e2e/terminal-cold-activation-deferral.spec.ts","tests/e2e/cli-first-worktree-single-terminal.spec.ts","tests/e2e/unopened-workspace-agent-single-resume.spec.ts","tests/e2e/terminal-pane-close-layout-consistency.spec.ts","tests/e2e/golden-file-open-edit-save.spec.ts","tests/e2e/golden-source-control-open-diff.spec.ts","tests/e2e/paired-cli-terminal-graph-sync-tab-retention.spec.ts","tests/e2e/floating-workspace-pane-height.spec.ts","tests/e2e/editor-model-host-isolation.spec.ts","tests/e2e/editor-tab-selection-restore.spec.ts","tests/e2e/finished-agent-ghost-resume.spec.ts","tests/e2e/live-background-terminal-mount-authority.spec.ts","tests/e2e/workspace-back-forward-navigation.spec.ts","tests/e2e/worktree-lineage-toggle-shortcut.spec.ts","tests/e2e/local-cli-terminal-retention.spec.ts","tests/e2e/default-tabs-mark-crash-relaunch.spec.ts","tests/e2e/terminal-cli-split-closed-before-mount.spec.ts","tests/e2e/layout-stream-loss-reopen.spec.ts","tests/e2e/terminal-tab-close-restart-persistence.spec.ts","tests/e2e/artificial-opencode-terminal-load.spec.ts","tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts","tests/e2e/headless-paired-remote-terminal-reveal-grid.spec.ts","tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts","tests/e2e/headless-paired-remote-terminal-viewer-size.spec.ts","tests/e2e/headless-serve-browser-tab-restart.spec.ts","tests/e2e/headless-serve-cli-terminal-retention-parity.spec.ts","tests/e2e/headless-serve-desktop-activation.spec.ts","tests/e2e/headless-serve-focused-terminal-create.spec.ts","tests/e2e/headless-serve-mobile-chat-tab-order.spec.ts","tests/e2e/headless-serve-mobile-native-chat-identity.spec.ts","tests/e2e/headless-serve-session-history-naming-requests.spec.ts","tests/e2e/layout-compat-old-phone-on-new-server.spec.ts","tests/e2e/layout-compat-serve-restart-keeps-at-at-terminals.spec.ts","tests/e2e/paired-mobile-serve-restart-terminal-tabs.spec.ts","tests/e2e/paired-mobile-desktop-tab-composition.spec.ts","tests/e2e/paired-remote-terminal-serve-restart-binding.spec.ts","tests/e2e/paired-desktop-server-layout-frames.spec.ts","tests/e2e/ssh-orcad-"]' run_changed_e2e: true needs_build: true artifact_suffix: -layout-owner-switch-on diff --git a/src/main/runtime/rpc/methods/workspace-layout-paired-desktop.test.ts b/src/main/runtime/rpc/methods/workspace-layout-paired-desktop.test.ts index ff99f8b91f7..3b5e75ef88a 100644 --- a/src/main/runtime/rpc/methods/workspace-layout-paired-desktop.test.ts +++ b/src/main/runtime/rpc/methods/workspace-layout-paired-desktop.test.ts @@ -70,7 +70,7 @@ describe('the layout intents capability', () => { }) describe('layout methods on a layout-owner server', () => { - // Past the scope check a desktop meets the next one: a stream asked for as a request, or bad params. + // Past the scope check: a stream asked for as a request, or bad params. it.each([ ['layout.subscribe', 'method_not_supported'], ['layout.unsubscribe', 'invalid_argument'], diff --git a/src/main/runtime/rpc/methods/workspace-layout.test.ts b/src/main/runtime/rpc/methods/workspace-layout.test.ts index 36e538a4118..19afb6668e3 100644 --- a/src/main/runtime/rpc/methods/workspace-layout.test.ts +++ b/src/main/runtime/rpc/methods/workspace-layout.test.ts @@ -6,6 +6,12 @@ import { ALL_RPC_METHODS } from './index' import { RUNTIME_CAPABILITIES } from '../../../../shared/protocol-version' import { MOBILE_RPC_METHOD_ALLOWLIST } from '../../runtime-rpc/runtime-rpc-mobile-method-allowlist' +const paired = vi.hoisted(() => ({ ids: ['one', 'two'] })) +vi.mock('../../../../shared/runtime-environment-store', async (importOriginal) => ({ + ...(await importOriginal()), + listEnvironments: () => paired.ids.map((id) => ({ id })) +})) + const methods = eraseRpcMethods(ALL_RPC_METHODS) const subscribe = methods.find((method) => method.name === 'layout.subscribe') const unsubscribeMethod = methods.find((method) => method.name === 'layout.unsubscribe') @@ -162,5 +168,11 @@ describe('layout.getRemoteSnapshots', () => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only runtime from its context. const result = await getRemoteSnapshots.handler(undefined, { runtime } as RpcContext) expect(result).toEqual({ snapshots }) + + // A server unpaired since its snapshot was stored seeds nothing. + paired.ids = ['two'] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only runtime from its context. + const afterRemoval = await getRemoteSnapshots.handler(undefined, { runtime } as RpcContext) + expect(afterRemoval).toEqual({ snapshots: { two: snapshots.two } }) }) }) diff --git a/src/main/runtime/rpc/methods/workspace-layout.ts b/src/main/runtime/rpc/methods/workspace-layout.ts index e69d86efd07..9e28d9b4cf0 100644 --- a/src/main/runtime/rpc/methods/workspace-layout.ts +++ b/src/main/runtime/rpc/methods/workspace-layout.ts @@ -1,4 +1,4 @@ -// The read-only layout stream (design 2.4) and the server snapshot read (design 6.3). Paired +// The layout stream (design 2.4), the server snapshot read (design 6.3) and layout.apply. Paired // desktops may call them; a desktop relies on them only on a server advertising // `layout.intents.v1` (layout-intents-capability.ts). Not on the mobile allowlist. @@ -9,6 +9,8 @@ import { } from '../../../../shared/rpc-contract/workspace-layout-params' import type { WorkspaceLayoutStreamFrame } from '../../../../shared/workspace-layout/workspace-layout-stream-frames' import { defineMethod, defineStreamingMethod } from '../core' +import { getAppEnvironment } from '../../../../shared/app-environment' +import { listEnvironments } from '../../../../shared/runtime-environment-store' let layoutSubscriptionSeq = 0 @@ -75,7 +77,17 @@ export const WORKSPACE_LAYOUT_METHODS = [ name: 'layout.getRemoteSnapshots', permission: 'workspace', params: null, - handler: async (_params, { runtime }) => ({ snapshots: runtime.getRemoteLayoutSnapshots() }) + handler: async (_params, { runtime }) => { + // Only servers still paired: a removed server's last layout must not seed a view again. + const known = new Set( + listEnvironments(getAppEnvironment().getPath('userData')).map(({ id }) => id) + ) + return { + snapshots: Object.fromEntries( + Object.entries(runtime.getRemoteLayoutSnapshots()).filter(([id]) => known.has(id)) + ) + } + } }) ] diff --git a/src/renderer/src/app-shell/reconcile-hydrated-server-layout-frames-switch.test.ts b/src/renderer/src/app-shell/reconcile-hydrated-server-layout-frames-switch.test.ts new file mode 100644 index 00000000000..b8065f09dc7 --- /dev/null +++ b/src/renderer/src/app-shell/reconcile-hydrated-server-layout-frames-switch.test.ts @@ -0,0 +1,47 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { reconcileHydratedWorkspaceTabModels } from './reconcile-hydrated-workspace-tab-models' + +const hoisted = vi.hoisted(() => ({ + layoutOwnerIsMain: false, + showServerLayoutFrames: vi.fn(), + showStartupLayoutFrames: vi.fn() +})) + +vi.mock('../../../shared/layout-owner-switch', () => ({ + get ORCA_LAYOUT_OWNER_IS_MAIN() { + return hoisted.layoutOwnerIsMain + } +})) +vi.mock('../runtime/server-layout-frames', () => ({ + showServerLayoutFrames: hoisted.showServerLayoutFrames +})) +vi.mock('../runtime/workspace-layout-frame-sync', () => ({ + showStartupLayoutFrames: hoisted.showStartupLayoutFrames +})) +vi.mock('../runtime/sleeping-records-to-layout-owner', () => ({ + followSleepingRecordsInLayoutOwner: vi.fn() +})) + +describe('paired servers’ layout streams at restore', () => { + beforeEach(() => { + hoisted.showServerLayoutFrames.mockReset() + hoisted.showStartupLayoutFrames.mockReset() + }) + + it('open with the layout-owner switch on', () => { + hoisted.layoutOwnerIsMain = true + reconcileHydratedWorkspaceTabModels({ tabsByWorktree: { 'wt-a': [] } }, vi.fn()) + expect(hoisted.showStartupLayoutFrames).toHaveBeenCalledTimes(1) + expect(hoisted.showServerLayoutFrames).toHaveBeenCalledTimes(1) + }) + + it('never open with the switch off, though restore reconciled', () => { + hoisted.layoutOwnerIsMain = false + const reconcile = vi.fn() + reconcileHydratedWorkspaceTabModels({ tabsByWorktree: { 'wt-a': [] } }, reconcile) + // Presence: the restore path ran. + expect(reconcile).toHaveBeenCalledWith(['wt-a']) + expect(hoisted.showServerLayoutFrames).not.toHaveBeenCalled() + expect(hoisted.showStartupLayoutFrames).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.ts b/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.ts index 0928c279412..ffe8b31a231 100644 --- a/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.ts +++ b/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.ts @@ -1,6 +1,7 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' import { showStartupLayoutFrames } from '../runtime/workspace-layout-frame-sync' +import { showServerLayoutFrames } from '../runtime/server-layout-frames' import { followSleepingRecordsInLayoutOwner } from '../runtime/sleeping-records-to-layout-owner' /** Reconcile every workspace loaded during boot so stale unified-tab subsets converge. */ @@ -15,10 +16,11 @@ export function reconcileHydratedWorkspaceTabModels( reconcileWorktreeTabModels(reconciled) } // Switch point: from main's first layout snapshot on, the frames' workspaces replace what restore - // built for them. Records frames do not carry (sleeping agents, closed tabs, default-tab marks, + // built for them, and each capable paired server's do from its own stream's. Records frames do not carry (sleeping agents, closed tabs, default-tab marks, // what reattaches) and the view stay restore's; main records later resume-record changes itself. if (ORCA_LAYOUT_OWNER_IS_MAIN) { showStartupLayoutFrames() + showServerLayoutFrames() followSleepingRecordsInLayoutOwner() } return reconciled diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index 5416989ab12..0d71a9aa336 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -139,6 +139,8 @@ declare global { __e2ePtyAppliedSizeReadDelayMs?: number __terminalPtyPreSpawnE2EBarrier?: TerminalPtyPreSpawnE2EBarrier __endLocalLayoutStreamE2E?: LocalLayoutStreamE2EEnd + /** E2E builds only: whether a paired server's layout frames, not the mirror, draw the workspace. */ + __isFrameDrawnServerWorkspaceE2E?: (environmentId: string, worktreeId: string) => boolean } } diff --git a/src/renderer/src/runtime/layout-frame-stream.ts b/src/renderer/src/runtime/layout-frame-stream.ts new file mode 100644 index 00000000000..e397790357c --- /dev/null +++ b/src/renderer/src/runtime/layout-frame-stream.ts @@ -0,0 +1,68 @@ +// The layout stream a frame sync reads: this window's own main's, or a paired server's under this +// window's ids. Only the local stream's positions answer this window's layout.apply replies. + +import type { AppState } from '@/store/types' +import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' +import type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames' +import { + beginLayoutFramePositions, + endLayoutFramePositions, + noteLayoutFramePosition +} from './layout-frame-position' +import { subscribeRuntimeLayout, type RuntimeLayoutStreamStatus } from './runtime-layout-client' +import { framesDrawWorkspace } from './workspace-layout-frame-drawn-workspaces' + +/** Whose layout stream a sync shows, and which of the window's workspaces it draws. */ +export type LayoutFrameSource = { + target: RuntimeClientTarget + draws: (state: AppState) => (key: string) => boolean + /** Frames under this window's ids; the local stream's already are. */ + translate?: (frame: WorkspaceLayoutChangeFrame) => WorkspaceLayoutChangeFrame + onStatus?: (status: RuntimeLayoutStreamStatus) => void +} + +export const LOCAL_LAYOUT_FRAMES: LayoutFrameSource = { + target: { kind: 'local' }, + draws: framesDrawWorkspace +} + +/** `onOpen(false)` once no frame will come, `onOpen(true)` when a lost stream reopens. */ +export function openLayoutFrameStream( + source: LayoutFrameSource, + receive: (frame: WorkspaceLayoutChangeFrame) => void, + onOpen: (open: boolean) => void +): { close: () => void } { + const local = source.target.kind === 'local' + let open = true + if (local) { + beginLayoutFramePositions() + } + return subscribeRuntimeLayout( + source.target, + {}, + (frame) => receive(source.translate ? source.translate(frame) : frame), + (status) => { + if (status.state === 'lost' || status.state === 'refused') { + console.warn('[layout-owner] layout stream stopped:', status) + } + if (status.state === 'connecting' && !open) { + // The stream reopens after a loss; replies wait for its frames again. + open = true + if (local) { + beginLayoutFramePositions() + } + onOpen(true) + } else if (status.state !== 'connecting' && status.state !== 'live') { + open = false + if (local) { + endLayoutFramePositions() + } + onOpen(false) + } + source.onStatus?.(status) + }, + // After the frame's flush. A held tab may still show as the window drew it, so a waiting reply + // checks the store rather than assume the frame shows. + (seq) => queueMicrotask(() => local && open && noteLayoutFramePosition(seq)) + ) +} diff --git a/src/renderer/src/runtime/runtime-layout-client.test.ts b/src/renderer/src/runtime/runtime-layout-client.test.ts index 6ca85cca462..bcfba6751e6 100644 --- a/src/renderer/src/runtime/runtime-layout-client.test.ts +++ b/src/renderer/src/runtime/runtime-layout-client.test.ts @@ -206,46 +206,6 @@ describe.each(targets)('runtime layout client over the %s transport', (_name, ta expect(positions).toEqual([3, 4, 5, 7]) }) - it.skipIf(target.kind === 'local')( - 'after a lost stream, keeps the last state and never reopens it', - async () => { - subscribe() - const first = await opened(1) - first.send( - snapshot([ - { key: 'a', layout: layout('a', ['t1', 't2']) }, - { key: 'b', layout: layout('b') } - ]) - ) - first.drop() - expect(first.unsubscribed).toBe(true) - expect(status).toEqual({ state: 'lost' }) - await vi.advanceTimersByTimeAsync(60_000) - expect(host.streams).toHaveLength(1) - // The last state stays; a late frame from the dropped stream changes nothing. - first.send({ type: 'removed', key: 'a' }) - expect(cache).toEqual({ a: layout('a', ['t1', 't2']), b: layout('b') }) - } - ) - - it.each([ - ['a failed subscribe', () => host.rejectNextSubscribe(), async () => {}], - ['an error frame', () => {}, async () => (await opened(1)).fail('stream failed')] - ])('stops after %s without retrying', async (_case, before, fail) => { - if (target.kind === 'local') { - return - } - before() - const subscription = subscribe() - await fail() - await vi.advanceTimersByTimeAsync(60_000) - expect(status).toEqual({ state: 'lost' }) - expect(host.streams.length).toBeLessThanOrEqual(1) - expect(host.streams.every((stream) => stream.unsubscribed)).toBe(true) - subscription.close() - expect(status).toEqual({ state: 'lost' }) - }) - it.each([ [ 'method_not_found', @@ -388,10 +348,7 @@ describe.each(targets)( await vi.advanceTimersByTimeAsync(999) expect(host.streams).toHaveLength(4) await vi.advanceTimersByTimeAsync(1) - expect(host.streams).toHaveLength(target.kind === 'local' ? 5 : 4) - if (target.kind !== 'local') { - return - } + expect(host.streams).toHaveLength(5) // Still failing: the reopened stream's snapshot keeps the backoff growing. const fifth = await opened(5) fifth.send(snapshot('epoch-1', 3, ['a'])) @@ -412,9 +369,6 @@ describe.each(targets)( }) it('starts the backoff over after one resync, once the stream is healthy again', async () => { - if (target.kind !== 'local') { - return - } const first = await opened(1) first.send(snapshot('epoch-1', 3, ['a'])) first.send(regrouped('epoch-1', 5, 'a', ['t1'])) @@ -467,7 +421,7 @@ describe.each(targets)( } ) -describe('the local layout stream after a loss', () => { +describe.each(targets)('the %s layout stream after a loss', (_name, target) => { let host: FakeHost let cache: WorkspaceLayoutCache let statuses: RuntimeLayoutStreamStatus[] @@ -475,7 +429,7 @@ describe('the local layout stream after a loss', () => { beforeEach(() => { vi.useFakeTimers() vi.spyOn(console, 'warn').mockImplementation(() => {}) - host = installFakeHost({ kind: 'local' }) + host = installFakeHost(target) cache = EMPTY_WORKSPACE_LAYOUT_CACHE statuses = [] }) @@ -488,7 +442,7 @@ describe('the local layout stream after a loss', () => { const subscribe = () => subscribeRuntimeLayout( - { kind: 'local' }, + target, {}, (frame) => { cache = applyLayoutFrame(cache, frame) @@ -550,6 +504,15 @@ describe('the local layout stream after a loss', () => { expect(host.streams).toHaveLength(2) }) + it('reopens after an error frame', async () => { + subscribe() + ;(await opened(1)).fail('stream failed') + expect(statuses.at(-1)).toEqual({ state: 'lost' }) + await vi.advanceTimersByTimeAsync(1_000) + ;(await opened(2)).send(snapshot([{ key: 'a', layout: layout('a') }])) + expect(statuses.at(-1)).toEqual({ state: 'live' }) + }) + it('never reopens after a refusal', async () => { subscribe() ;(await opened(1)).fail('no layout stream', 'method_not_found') diff --git a/src/renderer/src/runtime/runtime-layout-client.ts b/src/renderer/src/runtime/runtime-layout-client.ts index 62dfaedca3c..83afe8404ea 100644 --- a/src/renderer/src/runtime/runtime-layout-client.ts +++ b/src/renderer/src/runtime/runtime-layout-client.ts @@ -23,16 +23,15 @@ export type RuntimeLayoutStreamStatus = // An older host without the method, or a caller scope denied it: a retry gets the same answer. const REFUSAL_CODES = ['method_not_found', 'forbidden'] as const -/** First wait before a lost local stream reopens; it doubles to the cap until a snapshot comes. */ -const LOCAL_REOPEN_DELAY_MS = 1_000 -const LOCAL_REOPEN_DELAY_CAP_MS = 30_000 +/** First wait before a lost stream reopens; it doubles to the cap until a snapshot comes. */ +const REOPEN_DELAY_MS = 1_000 +const REOPEN_DELAY_CAP_MS = 30_000 /** Immediate resubscribes allowed before a frame applies cleanly; then the loss path's backoff. */ const RESYNC_BUDGET = 3 /** - * Opens the stream; `onFrame` gets a snapshot first, then that stream's changes. A lost local - * stream reports `lost`, then reopens, and its snapshot replaces what the caller holds. A remote - * stream that drops reports `lost`; reopening it belongs to the connection's reconnect. + * Opens the stream; `onFrame` gets a snapshot first, then that stream's changes. A lost stream + * reports `lost`, then reopens after a backoff, and its snapshot replaces what the caller holds. * * A live stream that misses a frame resubscribes at once for a fresh snapshot: a records frame of * another epoch or out of sequence, an unreadable frame, or one `onFrame` cannot apply. @@ -51,7 +50,7 @@ export function subscribeRuntimeLayout( let handle: { unsubscribe: () => void } | null = null let state: RuntimeLayoutStreamStatus['state'] = 'connecting' let reopenTimer: ReturnType | null = null - let reopenDelay = LOCAL_REOPEN_DELAY_MS + let reopenDelay = REOPEN_DELAY_MS /** The snapshot's epoch and the last position this stream applied, once a host sends epochs. */ let epoch: string | undefined let lastSeq: number | undefined @@ -85,14 +84,14 @@ export function subscribeRuntimeLayout( } const refusal = REFUSAL_CODES.find((code) => hasRuntimeRpcErrorCode(error, code)) stop(refusal ? { state: 'refused', code: refusal } : { state: 'lost' }) - // Why: layout.apply still reaches main without the stream, so only main's own changes would - // go unseen until a relaunch. - if (!refusal && target.kind === 'local') { + // Why: layout.apply still reaches main without the stream, so main's own changes would go + // unseen until a relaunch; a server's would until its connection is rebuilt. + if (!refusal) { reopenTimer = setTimeout(() => { reopenTimer = null void open() }, reopenDelay) - reopenDelay = Math.min(reopenDelay * 2, LOCAL_REOPEN_DELAY_CAP_MS) + reopenDelay = Math.min(reopenDelay * 2, REOPEN_DELAY_CAP_MS) } } @@ -154,7 +153,7 @@ export function subscribeRuntimeLayout( if (frame.type === 'snapshot') { // During a resync storm the backoff keeps growing; a snapshot alone proves nothing. if (!storm) { - reopenDelay = LOCAL_REOPEN_DELAY_MS + reopenDelay = REOPEN_DELAY_MS } epoch = frame.epoch lastSeq = frame.seq @@ -177,7 +176,7 @@ export function subscribeRuntimeLayout( resyncs = 0 if (storm) { storm = false - reopenDelay = LOCAL_REOPEN_DELAY_MS + reopenDelay = REOPEN_DELAY_MS } return } diff --git a/src/renderer/src/runtime/server-layout-frame-ids.test.ts b/src/renderer/src/runtime/server-layout-frame-ids.test.ts new file mode 100644 index 00000000000..42c9e6a56fc --- /dev/null +++ b/src/renderer/src/runtime/server-layout-frame-ids.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it } from 'vitest' +import { + toRemoteRuntimePtyId, + parseRemoteRuntimePtyId +} from '../../../shared/remote-runtime-pty-id' +import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' +import { ownedEditorFileId } from '../../../shared/workspace-session-pane-ownership' +import type { + PublishedTab, + PublishedWorkspaceLayout +} from '../../../shared/workspace-layout/workspace-layout-published' +import { + createServerLayoutFrameTranslator, + desktopLayoutForServer, + serverLayoutForDesktop, + type ServerTerminalHandleOf +} from './server-layout-frame-ids' + +const ENV = 'env-1' +const WT = 'repo-1::/srv/app' +const OWNED = ownedEditorFileId('/srv/app/b.ts', WT, undefined) + +function tab(fields: Partial & Pick): PublishedTab { + return { + entityId: fields.id, + executionHostId: 'local', + createdAt: 1, + customTitle: null, + color: null, + ...fields + } +} + +function terminal(id: string, entityId: string, leaves: [string, string?][]): PublishedTab { + return tab({ + id, + entityId, + kind: 'terminal', + terminal: { + root: { type: 'leaf', leafId: leaves[0]![0] }, + panes: leaves.map(([leafId, ptyId]) => ({ leafId, ...(ptyId ? { ptyId } : {}) })) + } + }) +} + +/** A server's workspace with every id kind: terminals (one with its own tab-bar id), editors by + * path and by owned id, a browser tab and a chat. */ +function serverLayout(): PublishedWorkspaceLayout { + return { + worktreeId: WT, + groups: [ + { id: 'g-1', tabIds: ['tab-1', 'entry-2', '/srv/app/a.ts', 'ed-2', 'br-1', 'chat-1'] } + ], + tabs: [ + terminal('tab-1', 'tab-1', [['leaf-1', 'pty-1'], ['leaf-2']]), + terminal('entry-2', 'term-2', [['leaf-3', 'pty-3']]), + tab({ id: '/srv/app/a.ts', kind: 'editor' }), + tab({ id: 'ed-2', entityId: OWNED, kind: 'editor' }), + tab({ id: 'br-1', entityId: 'browser-1', kind: 'browser' }), + tab({ id: 'chat-1', entityId: 'session-1', kind: 'agent-session' }) + ], + editorFiles: [ + { filePath: '/srv/app/a.ts', relativePath: 'a.ts', language: 'typescript' }, + { + filePath: '/srv/app/b.ts', + relativePath: 'b.ts', + language: 'typescript', + externalSshTargetId: 'servers-own-target' + } + ], + browserTabs: [{ id: 'browser-1', createdAt: 1 }] + } +} + +/** session.tabs' handles: one per bound pane, named after its host surface. */ +const handles: ServerTerminalHandleOf = (hostTabId, leafId) => `term_${hostTabId}_${leafId}` + +describe('a server layout under desktop ids', () => { + const desktop = serverLayoutForDesktop(serverLayout(), ENV, handles) + + it('names each id the way the session.tabs mirror names it', () => { + expect(desktop.groups[0]!.tabIds).toEqual([ + toWebTerminalSurfaceTabId('tab-1'), + toWebTerminalSurfaceTabId('term-2'), + '/srv/app/a.ts', + 'ed-2', + 'br-1', + 'chat-1' + ]) + expect(desktop.tabs.map((entry) => [entry.id, entry.entityId])).toEqual([ + [toWebTerminalSurfaceTabId('tab-1'), toWebTerminalSurfaceTabId('tab-1')], + [toWebTerminalSurfaceTabId('term-2'), toWebTerminalSurfaceTabId('term-2')], + ['/srv/app/a.ts', '/srv/app/a.ts'], + ['ed-2', ownedEditorFileId('/srv/app/b.ts', WT, ENV)], + ['br-1', 'browser-1'], + ['chat-1', 'session-1'] + ]) + expect(new Set(desktop.tabs.map((entry) => entry.executionHostId))).toEqual( + new Set(['runtime:env-1']) + ) + expect(desktop.editorFiles).toEqual([ + { + filePath: '/srv/app/a.ts', + relativePath: 'a.ts', + language: 'typescript', + runtimeEnvironmentId: ENV + }, + { + filePath: '/srv/app/b.ts', + relativePath: 'b.ts', + language: 'typescript', + runtimeEnvironmentId: ENV + } + ]) + expect(desktop.browserTabs).toEqual(serverLayout().browserTabs) + }) + + it('binds a pane to its session.tabs handle, and a bound pane without one stays pending', () => { + const panes = desktop.tabs[0]!.terminal!.panes + expect(panes[0]).toEqual({ + leafId: 'leaf-1', + ptyId: toRemoteRuntimePtyId('term_tab-1_leaf-1', ENV) + }) + expect(panes[1]).toEqual({ leafId: 'leaf-2' }) + const pending = serverLayoutForDesktop(serverLayout(), ENV, () => null) + expect(pending.tabs[0]!.terminal!.panes[0]).toEqual({ leafId: 'leaf-1' }) + }) + + it('maps every id kind desktop to server to desktop as the identity', () => { + const handleOfPtyId: ServerTerminalHandleOf = (hostTabId, leafId) => { + const pane = desktop.tabs + .find((entry) => entry.entityId === toWebTerminalSurfaceTabId(hostTabId)) + ?.terminal?.panes.find((candidate) => candidate.leafId === leafId) + return pane?.ptyId ? parseRemoteRuntimePtyId(pane.ptyId)!.handle : null + } + // The server's own panes are bound wherever the desktop's are. + const server = desktopLayoutForServer(desktop) + const boundServer = { + ...server, + tabs: server.tabs.map((entry) => + entry.terminal + ? { + ...entry, + terminal: { + ...entry.terminal, + panes: entry.terminal.panes.map((pane) => + handleOfPtyId(entry.entityId, pane.leafId) ? { ...pane, ptyId: 'bound' } : pane + ) + } + } + : entry + ) + } + expect(serverLayoutForDesktop(boundServer, ENV, handleOfPtyId)).toEqual(desktop) + }) + + it('refuses a tab kind it does not know rather than show it under raw ids', () => { + const layout = serverLayout() + // Through JSON, as the wire delivers it: no TabContentType names this kind. + const future: PublishedTab = JSON.parse( + JSON.stringify({ ...layout.tabs[2]!, id: 'future-1', kind: 'hologram' }) + ) + layout.tabs.push(future) + expect(() => serverLayoutForDesktop(layout, ENV, handles)).toThrow('unknown tab kind') + }) +}) + +describe('one server stream translated in order', () => { + it('re-diffs a records frame under desktop ids, and the same frame twice gives the same ids', () => { + const before = serverLayout() + const after = structuredClone(before) + after.groups[0]!.tabIds.reverse() + const translator = createServerLayoutFrameTranslator(ENV, () => handles) + translator.translate({ + type: 'snapshot', + workspaces: [{ key: WT, layout: before }], + seq: 1, + epoch: 'e' + }) + const frame = translator.translate({ + type: 'records', + epoch: 'e', + seq: 2, + workspaces: [ + { key: WT, change: 'records', workspace: { worktreeId: WT, groups: after.groups } } + ] + }) + expect(frame).toMatchObject({ + type: 'records', + seq: 2, + workspaces: [ + { + key: WT, + change: 'records', + workspace: { + groups: [ + { id: 'g-1', tabIds: serverLayoutForDesktop(after, ENV, handles).groups[0]!.tabIds } + ] + } + } + ] + }) + const again = createServerLayoutFrameTranslator(ENV, () => handles) + again.translate({ + type: 'snapshot', + workspaces: [{ key: WT, layout: after }], + seq: 2, + epoch: 'e' + }) + expect(again.rebind(WT)).toBeNull() + }) + + it('rebinds only the panes whose session.tabs handle changed', () => { + let known = new Map() + const translator = createServerLayoutFrameTranslator( + ENV, + () => (hostTabId, leafId) => known.get(`${hostTabId}/${leafId}`) ?? null + ) + translator.translate({ + type: 'snapshot', + workspaces: [{ key: WT, layout: serverLayout() }], + seq: 4, + epoch: 'e' + }) + expect(translator.rebind(WT)).toBeNull() + known = new Map([['tab-1/leaf-1', 'term_a']]) + expect(translator.rebind(WT)).toEqual({ + type: 'records', + epoch: 'e', + seq: 4, + workspaces: [ + { + key: WT, + change: 'records', + bindings: { + set: [{ leafId: 'leaf-1', ptyId: toRemoteRuntimePtyId('term_a', ENV) }] + } + } + ] + }) + expect(translator.rebind(WT)).toBeNull() + }) +}) diff --git a/src/renderer/src/runtime/server-layout-frame-ids.ts b/src/renderer/src/runtime/server-layout-frame-ids.ts new file mode 100644 index 00000000000..4751782142f --- /dev/null +++ b/src/renderer/src/runtime/server-layout-frame-ids.ts @@ -0,0 +1,297 @@ +// A paired server's layout frames under the ids this desktop already gives that server's tabs (the +// session.tabs mirror's), so a workspace moves between the two sources without a remount. The one +// place server ids become desktop ids; layout.apply maps back through the same rules. Frames carry +// no terminal handle: a pane binds the handle session.tabs gives its (host tab, leaf) surface. + +import { + LOCAL_EXECUTION_HOST_ID, + toRuntimeExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import { toRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' +import type { TabContentType } from '../../../shared/tab-types' +import { + isWebTerminalSurfaceTabId, + toHostSessionTabId, + toWebTerminalSurfaceTabId +} from '../../../shared/terminal-surface-id' +import { ownedEditorFileId } from '../../../shared/workspace-session-pane-ownership' +import type { + PublishedEditorFile, + PublishedPane, + PublishedTab, + PublishedWorkspaceLayout +} from '../../../shared/workspace-layout/workspace-layout-published' +import { + applyWorkspaceLayoutRecordChange, + diffPublishedLayouts, + indexPublishedLayout, + type IndexedLayoutRecords, + type WorkspaceLayoutRecordChange +} from '../../../shared/workspace-layout/workspace-layout-records' +import type { + WorkspaceLayoutChangeFrame, + WorkspaceLayoutRecordsFrame +} from '../../../shared/workspace-layout/workspace-layout-stream-frames' + +/** Which tab ids a kind changes: a terminal's take the mirror's surface prefix, others keep theirs. */ +const TAB_ID_RULES: Record = { + terminal: 'terminal', + editor: 'kept', + diff: 'kept', + 'conflict-review': 'kept', + 'check-details': 'kept', + 'agent-session': 'kept', + browser: 'kept', + simulator: 'kept' +} + +function isKnownTabKind(kind: string): kind is TabContentType { + return Object.hasOwn(TAB_ID_RULES, kind) +} + +function tabIdRule(kind: string): 'terminal' | 'kept' { + const rule = isKnownTabKind(kind) ? TAB_ID_RULES[kind] : undefined + if (!rule) { + // A newer server's tab kind: drawing it under raw ids would mix two id spaces. + throw new Error(`server layout: unknown tab kind ${kind}`) + } + return rule +} + +/** An editor tab names its file by path, or by the owned id of the server's own (local) file. */ +function desktopEditorEntityId( + entityId: string, + layout: PublishedWorkspaceLayout, + environmentId: string +): string { + const file = layout.editorFiles.find( + (candidate) => + ownedEditorFileId(candidate.filePath, layout.worktreeId, candidate.runtimeEnvironmentId) === + entityId + ) + return file ? ownedEditorFileId(file.filePath, layout.worktreeId, environmentId) : entityId +} + +/** The handle session.tabs gives a server terminal tab's pane; null while it has none. */ +export type ServerTerminalHandleOf = (hostTabId: string, leafId: string) => string | null + +function desktopPane( + pane: PublishedPane, + hostTabId: string, + environmentId: string, + handleOf: ServerTerminalHandleOf +): PublishedPane { + const { ptyId, ptyGeneration, ...rest } = pane + const handle = ptyId ? handleOf(hostTabId, pane.leafId) : null + // A bound pane without a handle yet shows as pending, as the mirror's `pending-handle` does. + return handle + ? { + ...rest, + ptyId: toRemoteRuntimePtyId(handle, environmentId), + ...(ptyGeneration !== undefined ? { ptyGeneration } : {}) + } + : rest +} + +function desktopTab( + tab: PublishedTab, + layout: PublishedWorkspaceLayout, + host: ExecutionHostId, + environmentId: string, + handleOf: ServerTerminalHandleOf +): PublishedTab { + const terminal = tabIdRule(tab.kind) === 'terminal' + return { + ...tab, + // The mirror names a terminal tab by its terminal's id alone, tab-bar entry included. + id: terminal ? toWebTerminalSurfaceTabId(tab.entityId) : tab.id, + entityId: terminal + ? toWebTerminalSurfaceTabId(tab.entityId) + : tab.kind === 'editor' + ? desktopEditorEntityId(tab.entityId, layout, environmentId) + : tab.entityId, + executionHostId: host, + ...(tab.terminal + ? { + terminal: { + ...tab.terminal, + panes: tab.terminal.panes.map((pane) => + desktopPane(pane, tab.entityId, environmentId, handleOf) + ) + } + } + : {}) + } +} + +function desktopEditorFile(file: PublishedEditorFile, environmentId: string): PublishedEditorFile { + // The server's SSH target names a host of the server's, never one of this desktop's. + const { externalSshTargetId: _serverTarget, ...rest } = file + return { ...rest, runtimeEnvironmentId: environmentId } +} + +/** One workspace of a server's frames under this desktop's ids. */ +export function serverLayoutForDesktop( + layout: PublishedWorkspaceLayout, + environmentId: string, + handleOf: ServerTerminalHandleOf +): PublishedWorkspaceLayout { + const host = toRuntimeExecutionHostId(environmentId) + const terminalEntityByTabId = new Map( + layout.tabs + .filter((tab) => tabIdRule(tab.kind) === 'terminal') + .map((tab) => [tab.id, tab.entityId]) + ) + const tabId = (id: string) => { + const entityId = terminalEntityByTabId.get(id) + return entityId === undefined ? id : toWebTerminalSurfaceTabId(entityId) + } + return { + ...layout, + groups: layout.groups.map((group) => ({ ...group, tabIds: group.tabIds.map(tabId) })), + tabs: layout.tabs.map((tab) => desktopTab(tab, layout, host, environmentId, handleOf)), + editorFiles: layout.editorFiles.map((file) => desktopEditorFile(file, environmentId)), + browserTabs: layout.browserTabs + } +} + +/** A desktop tab id as the server knows it. */ +export function serverTabIdOf(desktopTabId: string): string { + return isWebTerminalSurfaceTabId(desktopTabId) ? toHostSessionTabId(desktopTabId) : desktopTabId +} + +/** A desktop editor tab's file id as the server knows it: its own file, owned by no environment. */ +function serverEditorEntityId(entityId: string, layout: PublishedWorkspaceLayout): string { + const file = layout.editorFiles.find( + (candidate) => + ownedEditorFileId(candidate.filePath, layout.worktreeId, candidate.runtimeEnvironmentId) === + entityId + ) + return file ? ownedEditorFileId(file.filePath, layout.worktreeId, undefined) : entityId +} + +/** + * A desktop-shaped workspace under the server's ids: the inverse of `serverLayoutForDesktop` for + * every id it maps. Panes keep no terminal: the desktop binds handles, which the server never takes. + */ +export function desktopLayoutForServer(layout: PublishedWorkspaceLayout): PublishedWorkspaceLayout { + return { + ...layout, + groups: layout.groups.map((group) => ({ ...group, tabIds: group.tabIds.map(serverTabIdOf) })), + tabs: layout.tabs.map((tab) => { + tabIdRule(tab.kind) + return { + ...tab, + id: serverTabIdOf(tab.id), + entityId: + tab.kind === 'editor' + ? serverEditorEntityId(tab.entityId, layout) + : serverTabIdOf(tab.entityId), + executionHostId: LOCAL_EXECUTION_HOST_ID, + ...(tab.terminal + ? { + terminal: { + ...tab.terminal, + panes: tab.terminal.panes.map( + ({ ptyId: _handle, ptyGeneration: _g, ...pane }) => pane + ) + } + } + : {}) + } + }), + editorFiles: layout.editorFiles.map(({ runtimeEnvironmentId: _env, ...file }) => file) + } +} + +/** One server stream's frames under desktop ids, and the changes a new terminal handle makes. */ +export type ServerLayoutFrameTranslator = { + translate: (frame: WorkspaceLayoutChangeFrame) => WorkspaceLayoutChangeFrame + /** A records frame binding the panes whose handle session.tabs changed; null when none did. */ + rebind: (key: string) => WorkspaceLayoutRecordsFrame | null + /** The workspaces the server's stream holds now. */ + keys: () => string[] +} + +/** + * Translates one server stream's frames in order. It keeps the server's copy of each workspace, so + * a records frame is re-diffed under desktop ids: a record's key can depend on another record (a + * group names terminal tabs by their kind), which one frame alone may not carry. + */ +export function createServerLayoutFrameTranslator( + environmentId: string, + handleOf: (worktreeId: string) => ServerTerminalHandleOf +): ServerLayoutFrameTranslator { + const server = new Map() + const desktop = new Map() + /** The last server frame's stream identity, which a desktop-made frame repeats. */ + let position = { epoch: '', seq: 0 } + + const show = (key: string, layout: PublishedWorkspaceLayout): Translated => { + const translated = serverLayoutForDesktop(layout, environmentId, handleOf(layout.worktreeId)) + const entry = { layout: translated, index: indexPublishedLayout(translated) } + server.set(key, layout) + desktop.set(key, entry) + return entry + } + const drop = (key: string): void => { + server.delete(key) + desktop.delete(key) + } + const changeTo = ( + key: string, + layout: PublishedWorkspaceLayout + ): WorkspaceLayoutRecordChange[] => { + const before = desktop.get(key) + const after = show(key, layout) + const diff = diffPublishedLayouts(key, before?.index ?? null, after.index, after.layout) + return diff ? [diff] : [] + } + + const recordChange = (change: WorkspaceLayoutRecordChange): WorkspaceLayoutRecordChange[] => { + if (change.change === 'removed') { + drop(change.key) + return [change] + } + if (change.change === 'layout') { + return [{ ...change, layout: show(change.key, change.layout).layout }] + } + // Throws for a change that does not fit: the stream then resubscribes for a snapshot. + return changeTo(change.key, applyWorkspaceLayoutRecordChange(server.get(change.key), change)!) + } + + return { + translate: (frame) => { + switch (frame.type) { + case 'snapshot': + server.clear() + desktop.clear() + position = { epoch: frame.epoch ?? '', seq: frame.seq ?? 0 } + return { + ...frame, + workspaces: frame.workspaces.map(({ key, layout }) => ({ + key, + layout: show(key, layout).layout + })) + } + case 'workspace': + return { ...frame, layout: show(frame.key, frame.layout).layout } + case 'removed': + drop(frame.key) + return frame + case 'records': + position = { epoch: frame.epoch, seq: frame.seq } + return { ...frame, workspaces: frame.workspaces.flatMap(recordChange) } + } + }, + keys: () => [...server.keys()], + rebind: (key) => { + const layout = server.get(key) + const workspaces = layout ? changeTo(key, layout) : [] + return workspaces.length > 0 ? { type: 'records', ...position, workspaces } : null + } + } +} + +type Translated = { layout: PublishedWorkspaceLayout; index: IndexedLayoutRecords | null } diff --git a/src/renderer/src/runtime/server-layout-frames-handover.test.ts b/src/renderer/src/runtime/server-layout-frames-handover.test.ts new file mode 100644 index 00000000000..560e585b2e7 --- /dev/null +++ b/src/renderer/src/runtime/server-layout-frames-handover.test.ts @@ -0,0 +1,368 @@ +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import type * as FrameDrawnModule from './web-session-tabs-sync/frame-drawn-server-workspaces' +import type { AppState } from '@/store/types' +import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' +import { toRuntimeExecutionHostId } from '../../../shared/execution-host' +import { toRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' +import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' +import { loadWorkspaceLayoutWithoutReport } from '../../../shared/workspace-layout/workspace-layout-load' +import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids' +import { + publishWorkspaceLayout, + type PublishedWorkspaceLayout +} from '../../../shared/workspace-layout/workspace-layout-published' +import { createStoreSessionMockApi } from '../store/slices/store-session-test-harness' +import { _resetLayoutFramePositionForTests } from './layout-frame-position' +import { mainLayoutFrameSender } from './main-loaded-layout-frames.test-fixture' +import { serverLayoutForDesktop } from './server-layout-frame-ids' +import { + CAPABLE, + ENV, + LEAF_ID, + WT, + SECOND_LEAF_ID, + SERVER_LAYOUT, + createFakeLayoutStreams, + createServerHarness, + mirrorSnapshot, + serverLayout, + serverTerminalTab, + senderSnapshot, + sessionTabs, + settle, + surface, + treeOf, + type TestStore +} from './server-layout-frames.test-fixture' +import { installLayoutFrameApplyCheck } from './workspace-layout-frame-apply-check.test-fixture' +import { + isFrameDrawnServerWorkspace, + resetFrameDrawnServerWorkspacesForTests +} from './web-session-tabs-sync/frame-drawn-server-workspaces' +import { resetMirroredTerminalHandlesForTests } from './web-session-tabs-sync/mirrored-terminal-handle-index' +import { resetWebSessionTabsSyncTestState } from './web-session-tabs-sync-test-harness' + +type Subscribe = (...args: never[]) => { close: () => void } +const hoisted = vi.hoisted( + (): { + owners: Map + resync: Mock<(environmentId: string, worktreeId: string) => Promise> + subscribe: Subscribe | null + } => ({ + owners: new Map(), + resync: vi.fn(), + subscribe: null + }) +) + +vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) +vi.mock('./runtime-layout-client', () => ({ + subscribeRuntimeLayout: (...args: never[]) => hoisted.subscribe!(...args) +})) +vi.mock('./web-session-tabs-sync/mirrored-workspace-owner', () => ({ + mirroredRuntimeEnvironmentIdForWorkspace: (_state: unknown, key: string) => + hoisted.owners.get(key) ?? null +})) +// Why not mock its RPC instead: vitest gives concurrent dynamic imports of a mocked module the real one. +vi.mock('./web-session-tabs-sync/frame-drawn-server-workspaces', async (importOriginal) => ({ + ...(await importOriginal()), + resyncMirroredServerWorkspace: hoisted.resync +})) + +createStoreSessionMockApi() + +const streams = createFakeLayoutStreams() +hoisted.subscribe = streams.subscribe + +const TAB_1 = toWebTerminalSurfaceTabId('host-tab-1') +const TAB_2 = toWebTerminalSurfaceTabId('host-tab-2') +const REMOTE_1 = toRemoteRuntimePtyId('terminal-1', ENV) +const { setServers, mirroredStore, install, uninstallAll } = createServerHarness(hoisted) + +/** The server's layout once it opened a second terminal tab, bound on the server. */ +const WITH_TAB_2 = serverLayout([ + serverTerminalTab('host-tab-1', LEAF_ID, 'server-pty-1'), + serverTerminalTab('host-tab-2', SECOND_LEAF_ID, 'server-pty-2', 2) +]) + +/** The workspace as the store shows it, in the published shape the frames translate to. */ +function publishedStoreTree(state: AppState): PublishedWorkspaceLayout { + const host = toRuntimeExecutionHostId(ENV) + const { layout } = loadWorkspaceLayoutWithoutReport( + host, + buildWorkspaceSessionPayload(state), + nameBasedLoadContext() + ) + return publishWorkspaceLayout(layout.workspaces[WT]!, host) +} + +/** Every state the store passes through, from now until `stop`. */ +function recordStates(store: TestStore) { + const states: AppState[] = [store.getState()] + const stop = store.subscribe((state) => states.push(state)) + return { states, stop } +} + +/** A mirrored store whose WT the server's stream now draws, shown from its snapshot. */ +async function frameDrawnStore(store = mirroredStore()) { + install(store) + setServers(store, [CAPABLE]) + const stream = streams.openTo(ENV) + const frames = mainLayoutFrameSender() + streams.send(stream, senderSnapshot(frames, { [WT]: SERVER_LAYOUT }, 1)) + await settle() + return { store, stream, frames } +} + +const bindingOf = (state: AppState, tabId: string, leafId: string) => + state.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId?.[leafId] + +/** How many times a pane's binding changed across the recorded states. */ +function bindingChanges(states: AppState[], tabId: string, leafId: string): number { + return states.filter( + (state, index) => + index > 0 && bindingOf(state, tabId, leafId) !== bindingOf(states[index - 1]!, tabId, leafId) + ).length +} + +let check: ReturnType +beforeEach(() => { + hoisted.owners.clear() + hoisted.resync.mockReset().mockResolvedValue(undefined) + streams.all.length = 0 + resetWebSessionTabsSyncTestState() + resetFrameDrawnServerWorkspacesForTests() + resetMirroredTerminalHandlesForTests() + _resetLayoutFramePositionForTests() + check = installLayoutFrameApplyCheck() +}) +afterEach(() => { + uninstallAll() + check.uninstall() + expect(check.failures).toEqual([]) +}) + +describe('mirror to frames', () => { + it('gates the workspace before the snapshot shows, then shows it with no empty state and no reattach', async () => { + const store = mirroredStore() + const mirrorTree = treeOf(store.getState(), WT) + expect(mirrorTree.bindings).toEqual({ [TAB_1]: { [LEAF_ID]: REMOTE_1 } }) + // The server's snapshot translates to exactly the tree the mirror drew. + const handleOf = (hostTabId: string, leafId: string) => + hostTabId === 'host-tab-1' && leafId === LEAF_ID ? 'terminal-1' : null + expect(serverLayoutForDesktop(SERVER_LAYOUT, ENV, handleOf)).toEqual( + publishedStoreTree(store.getState()) + ) + install(store) + setServers(store, [CAPABLE]) + const stream = streams.openTo(ENV) + const recorded = recordStates(store) + + streams.send(stream, { + type: 'snapshot', + workspaces: [{ key: WT, layout: SERVER_LAYOUT }], + seq: 1, + epoch: 'e' + }) + + // Before the frame's flush: the gate is up and the store still shows the mirror's tree. + expect(isFrameDrawnServerWorkspace(ENV, WT)).toBe(true) + expect(check.fullBuilt).toEqual([]) + expect(treeOf(store.getState(), WT)).toEqual(mirrorTree) + await settle() + recorded.stop() + + // Presence: the frames built the workspace; the same tree needed no store write at all. + expect(check.fullBuilt).toEqual([WT]) + expect(recorded.states).toHaveLength(1) + expect(treeOf(store.getState(), WT)).toEqual(mirrorTree) + }) + + it('writes what the frames change over the mirror’s tree without emptying it between', async () => { + const store = mirroredStore() + const mirrorTree = treeOf(store.getState(), WT) + expect(mirrorTree.tabIds).toEqual([TAB_1]) + install(store) + setServers(store, [CAPABLE]) + const recorded = recordStates(store) + const renamed = structuredClone(SERVER_LAYOUT) + renamed.tabs[0]!.customTitle = 'named on the server' + + streams.send(streams.openTo(ENV), { + type: 'snapshot', + workspaces: [{ key: WT, layout: renamed }], + seq: 1, + epoch: 'e' + }) + expect(isFrameDrawnServerWorkspace(ENV, WT)).toBe(true) + await settle() + recorded.stop() + + expect(store.getState().tabsByWorktree[WT]?.[0]?.customTitle).toBe('named on the server') + expect(recorded.states.length).toBeGreaterThan(1) + for (const state of recorded.states) { + expect(treeOf(state, WT)).toEqual(mirrorTree) + } + }) +}) + +describe('a terminal handle and the frame that binds its pane, in either order', () => { + it('shows the pane pending until session.tabs has its handle, then binds it once in place', async () => { + const { store, stream, frames } = await frameDrawnStore() + streams.send(stream, frames.records(WT, WITH_TAB_2)) + await settle() + const tabsBefore = (store.getState().tabsByWorktree[WT] ?? []).map((tab) => tab.id) + expect(tabsBefore).toEqual([TAB_1, TAB_2]) + const pending = store.getState().terminalLayoutsByTabId[TAB_2] + expect(pending?.root).toEqual({ type: 'leaf', leafId: SECOND_LEAF_ID }) + expect(pending?.ptyIdsByLeafId ?? {}).not.toHaveProperty(SECOND_LEAF_ID) + const recorded = recordStates(store) + + mirrorSnapshot( + store, + ENV, + sessionTabs( + [ + surface('host-tab-1', LEAF_ID, 'terminal-1'), + surface('host-tab-2', SECOND_LEAF_ID, 'terminal-2') + ], + 2 + ) + ) + await settle() + recorded.stop() + + const bound = toRemoteRuntimePtyId('terminal-2', ENV) + expect(bindingOf(store.getState(), TAB_2, SECOND_LEAF_ID)).toBe(bound) + expect(bindingChanges(recorded.states, TAB_2, SECOND_LEAF_ID)).toBe(1) + for (const state of recorded.states) { + expect((state.tabsByWorktree[WT] ?? []).map((tab) => tab.id)).toEqual(tabsBefore) + expect(state.terminalLayoutsByTabId[TAB_2]?.root).toEqual(pending?.root) + } + // The other pane kept its binding throughout. + expect(bindingChanges(recorded.states, TAB_1, LEAF_ID)).toBe(0) + expect(bindingOf(store.getState(), TAB_1, LEAF_ID)).toBe(REMOTE_1) + }) + + it('binds the pane in the frame’s own flush when session.tabs had its handle first', async () => { + const { store, stream, frames } = await frameDrawnStore() + mirrorSnapshot( + store, + ENV, + sessionTabs( + [ + surface('host-tab-1', LEAF_ID, 'terminal-1'), + surface('host-tab-2', SECOND_LEAF_ID, 'terminal-2') + ], + 2 + ) + ) + await settle() + // The gated mirror drew no tab of its own for the handle. + expect(store.getState().terminalLayoutsByTabId[TAB_2]).toBeUndefined() + const recorded = recordStates(store) + + streams.send(stream, frames.records(WT, WITH_TAB_2)) + await settle() + recorded.stop() + + const bound = toRemoteRuntimePtyId('terminal-2', ENV) + expect(bindingOf(store.getState(), TAB_2, SECOND_LEAF_ID)).toBe(bound) + // The first state holding the tab already holds its binding. + const firstShown = recorded.states.find((state) => state.terminalLayoutsByTabId[TAB_2]) + expect(bindingOf(firstShown!, TAB_2, SECOND_LEAF_ID)).toBe(bound) + expect(bindingChanges(recorded.states, TAB_2, SECOND_LEAF_ID)).toBe(1) + }) + + it('rebinds a pane that stays bound exactly once when its handle is replaced', async () => { + const { store } = await frameDrawnStore() + expect(bindingOf(store.getState(), TAB_1, LEAF_ID)).toBe(REMOTE_1) + const recorded = recordStates(store) + + mirrorSnapshot(store, ENV, sessionTabs([surface('host-tab-1', LEAF_ID, 'terminal-1b')], 2)) + await settle() + recorded.stop() + + expect(bindingOf(store.getState(), TAB_1, LEAF_ID)).toBe( + toRemoteRuntimePtyId('terminal-1b', ENV) + ) + expect(bindingChanges(recorded.states, TAB_1, LEAF_ID)).toBe(1) + for (const state of recorded.states) { + expect((state.tabsByWorktree[WT] ?? []).map((tab) => tab.id)).toEqual([TAB_1]) + } + }) +}) + +describe('a workspace handed back to the mirror', () => { + it('is not drawn from the stopped stream’s frames while the stream reconnects', async () => { + const other = 'repo::/other-worktree' + hoisted.owners.set(other, ENV) + const store = mirroredStore() + install(store) + setServers(store, [CAPABLE]) + const stream = streams.openTo(ENV) + const frames = mainLayoutFrameSender() + const otherLayout = { + ...serverLayout([serverTerminalTab('host-tab-9', LEAF_ID, 'server-pty-9')]), + worktreeId: other + } + streams.send(stream, senderSnapshot(frames, { [WT]: SERVER_LAYOUT, [other]: otherLayout }, 1)) + await settle() + stream.onStatus({ state: 'lost' }) + stream.onStatus({ state: 'connecting' }) + await settle() + + // The mirror's first snapshot of the other workspace, after it took it back. + mirrorSnapshot(store, ENV, { + ...sessionTabs([surface('host-tab-8', LEAF_ID, 'terminal-8')], 1), + worktree: other + }) + await settle() + + expect((store.getState().tabsByWorktree[other] ?? []).map((tab) => tab.id)).toEqual([ + toWebTerminalSurfaceTabId('host-tab-8') + ]) + }) +}) + +describe('two desktop windows', () => { + it('bind identical ptyIds from the same server frames and handles', async () => { + const first = mirroredStore() + const second = mirroredStore() + install(first) + install(second) + setServers(first, [CAPABLE]) + setServers(second, [CAPABLE]) + const open = streams.of(ENV).filter((stream) => !stream.closed) + expect(open).toHaveLength(2) + for (const stream of open) { + const frames = mainLayoutFrameSender() + streams.send(stream, senderSnapshot(frames, { [WT]: SERVER_LAYOUT }, 1)) + streams.send(stream, frames.records(WT, WITH_TAB_2)) + } + await settle() + const handles = sessionTabs( + [ + surface('host-tab-1', LEAF_ID, 'terminal-1'), + surface('host-tab-2', SECOND_LEAF_ID, 'terminal-2') + ], + 2 + ) + mirrorSnapshot(first, ENV, handles) + mirrorSnapshot(second, ENV, handles) + await settle() + + const expected = { + [TAB_1]: { [LEAF_ID]: REMOTE_1 }, + [TAB_2]: { [SECOND_LEAF_ID]: toRemoteRuntimePtyId('terminal-2', ENV) } + } + expect(treeOf(first.getState(), WT).bindings).toEqual(expected) + expect(treeOf(second.getState(), WT)).toEqual(treeOf(first.getState(), WT)) + }) +}) diff --git a/src/renderer/src/runtime/server-layout-frames.test-fixture.ts b/src/renderer/src/runtime/server-layout-frames.test-fixture.ts new file mode 100644 index 00000000000..ef011e235c0 --- /dev/null +++ b/src/renderer/src/runtime/server-layout-frames.test-fixture.ts @@ -0,0 +1,303 @@ +// A paired server for the server layout frame tests: its session.tabs snapshots, its layout in the +// server's own ids, its status entry, and the layout streams the desktop opens to it. + +import type { RuntimeEnvironmentStatus } from '../../../shared/runtime-host-status' +import type { RuntimeMobileSessionTabsResult, RuntimeStatus } from '../../../shared/runtime-types' +import { LAYOUT_INTENTS_RUNTIME_CAPABILITY } from '../../../shared/workspace-layout/layout-intents-capability' +import type { + PublishedTab, + PublishedWorkspaceLayout +} from '../../../shared/workspace-layout/workspace-layout-published' +import type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames' +import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' +import type { AppState } from '@/store/types' +import type { RuntimeLayoutStreamStatus } from './runtime-layout-client' +import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-environments' +import { createTestStore } from '../store/slices/store-test-helpers' +import { installServerLayoutFrames } from './server-layout-frames' +import { applyWebSessionTabsSnapshotWithContext } from './web-session-tabs-sync/apply-snapshot' + +export const ENV = 'web-env-1' +export const WT = 'repo::/worktree' +export const NOW = 1_700_000_000_000 +export const LEAF_ID = '11111111-1111-4111-8111-111111111111' +export const SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222' + +export const HOST_GROUP = 'host-group-1' + +/** One host terminal surface; a null handle is session.tabs' `pending-handle`. */ +export function surface(hostTabId: string, leafId: string, terminal: string | null) { + const base = { + type: 'terminal' as const, + id: `${hostTabId}::${leafId}`, + title: hostTabId, + parentTabId: hostTabId, + leafId, + isActive: hostTabId === 'host-tab-1' + } + return terminal === null + ? { ...base, status: 'pending-handle' as const, terminal: null } + : { ...base, status: 'ready' as const, terminal } +} + +export function sessionTabs( + surfaces: ReturnType[], + snapshotVersion: number +): RuntimeMobileSessionTabsResult { + return { + worktree: WT, + publicationEpoch: 'epoch-1', + snapshotVersion, + activeGroupId: HOST_GROUP, + activeTabId: surfaces.find((tab) => tab.isActive)?.id ?? null, + activeTabType: 'terminal', + tabs: surfaces + } +} + +/** A terminal tab as the server publishes it: its own ids, its own pty. */ +export function serverTerminalTab( + hostTabId: string, + leafId: string, + serverPtyId: string | null, + createdAt = NOW +): PublishedTab { + return { + id: hostTabId, + kind: 'terminal', + entityId: hostTabId, + executionHostId: 'local', + createdAt, + customTitle: null, + color: null, + isPinned: false, + isPreview: false, + terminal: { + root: { type: 'leaf', leafId }, + panes: [{ leafId, ...(serverPtyId ? { ptyId: serverPtyId } : {}) }], + defaultTitle: hostTabId + } + } +} + +export function serverLayout(tabs: PublishedTab[]): PublishedWorkspaceLayout { + return { + worktreeId: WT, + groups: [{ id: HOST_GROUP, tabIds: tabs.map((tab) => tab.id) }], + groupLayout: { type: 'leaf', groupId: HOST_GROUP }, + tabs, + editorFiles: [], + browserTabs: [] + } +} + +export function runtimeStatusEntry( + runtimeId: string, + capable: boolean, + disconnected = false +): RuntimeEnvironmentStatus { + const status: RuntimeStatus = { + runtimeId, + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + ...(capable ? { capabilities: [LAYOUT_INTENTS_RUNTIME_CAPABILITY] } : {}) + } + if (!disconnected) { + return { status, checkedAt: 1 } + } + // A retired pairing: the verified status stays, and the host reads disconnected. + return { + status, + checkedAt: 1, + snapshot: { + environmentId: 'unused', + pairingRevision: 1, + sequence: 1, + checkedAt: 1, + status, + verification: 'verified', + transport: 'ready', + retired: true + } + } +} + +/** A paired server as the catalog lists it. */ +export function runtimeEnvironment( + id: string, + runtimeId: string, + pairingRevision: number +): PublicKnownRuntimeEnvironment { + return { + id, + name: id, + createdAt: 1, + updatedAt: 1, + pairingRevision, + lastUsedAt: null, + runtimeId, + endpoints: [{ id: 'ws-1', kind: 'websocket', label: 'WebSocket', endpoint: 'ws://server' }], + preferredEndpointId: 'ws-1' + } +} + +type MirrorStore = { + getState: () => AppState + setState: (patch: Partial) => void +} + +/** Applies one session.tabs snapshot the way the mirror commits it. */ +export function mirrorSnapshot( + store: MirrorStore, + environmentId: string, + snapshot: RuntimeMobileSessionTabsResult +): void { + store.setState( + applyWebSessionTabsSnapshotWithContext(store.getState(), snapshot, environmentId, NOW) + ) +} + +export type FakeLayoutStream = { + target: RuntimeClientTarget + onFrame: (frame: WorkspaceLayoutChangeFrame) => void + onStatus: (status: RuntimeLayoutStreamStatus) => void + onPosition: ((seq: number) => void) | undefined + close: () => void + closed: boolean +} + +/** Every layout subscription the code under test opened, in order. */ +export function createFakeLayoutStreams() { + const all: FakeLayoutStream[] = [] + const subscribe = ( + target: RuntimeClientTarget, + _params: unknown, + onFrame: FakeLayoutStream['onFrame'], + onStatus: FakeLayoutStream['onStatus'], + onPosition?: (seq: number) => void + ): { close: () => void } => { + const stream: FakeLayoutStream = { + target, + onFrame, + onStatus, + onPosition, + closed: false, + close: () => { + stream.closed = true + } + } + all.push(stream) + return { close: () => stream.close() } + } + const of = (environmentId: string): FakeLayoutStream[] => + all.filter( + (stream) => + stream.target.kind === 'environment' && stream.target.environmentId === environmentId + ) + /** The one open stream to a server; throws unless exactly one is open. */ + const openTo = (environmentId: string): FakeLayoutStream => { + const open = of(environmentId).filter((stream) => !stream.closed) + if (open.length !== 1) { + throw new Error(`expected one open stream to ${environmentId}, found ${open.length}`) + } + return open[0]! + } + /** Delivers a frame as the client does: the frame, then its position. */ + const send = (stream: FakeLayoutStream, frame: WorkspaceLayoutChangeFrame): void => { + stream.onFrame(frame) + const seq = 'seq' in frame ? frame.seq : undefined + if (seq !== undefined) { + stream.onPosition?.(seq) + } + } + return { all, subscribe, of, openTo, send } +} + +export const settle = () => new Promise((resolve) => setTimeout(resolve, 0)) + +/** What a reattach or an emptied workspace would change: tab ids, groups and pane bindings. */ +export function treeOf(state: AppState, key: string) { + const tabIds = (state.tabsByWorktree[key] ?? []).map((tab) => tab.id) + return { + tabIds, + unifiedIds: (state.unifiedTabsByWorktree[key] ?? []).map((tab) => tab.id), + groups: (state.groupsByWorktree[key] ?? []).map((group) => [group.id, group.tabOrder]), + bindings: Object.fromEntries( + tabIds.map((id) => [id, state.terminalLayoutsByTabId[id]?.ptyIdsByLeafId]) + ) + } +} + +export type Server = { + environmentId: string + runtimeId: string + pairingRevision?: number + capable: boolean + /** Paired still, but the host's own verdict is that the pairing is over. */ + disconnected?: boolean +} + +export const CAPABLE: Server = { environmentId: ENV, runtimeId: 'runtime-1', capable: true } + +/** ENV's one workspace as the server publishes it, bound to its own `server-pty-1`. */ +export const SERVER_LAYOUT = serverLayout([ + serverTerminalTab('host-tab-1', LEAF_ID, 'server-pty-1') +]) + +export type TestStore = ReturnType + +/** The reachable servers and workspace owners a test's mocks read, and its installed managers. */ +export function createServerHarness(mocks: { owners: Map }) { + const installs: (() => void)[] = [] + return { + /** The paired servers, and what each one's verified status advertises. */ + setServers: (store: TestStore, servers: Server[]): void => { + store.setState({ + runtimeEnvironments: servers.map((server) => + runtimeEnvironment(server.environmentId, server.runtimeId, server.pairingRevision ?? 1) + ), + runtimeStatusByEnvironmentId: new Map( + servers.map((server) => [ + server.environmentId, + runtimeStatusEntry(server.runtimeId, server.capable, server.disconnected) + ]) + ) + }) + }, + /** A store whose WT mirrors ENV's one terminal, bound to `terminal-1`. */ + mirroredStore: (): TestStore => { + const store = createTestStore() + mocks.owners.set(WT, ENV) + mirrorSnapshot(store, ENV, sessionTabs([surface('host-tab-1', LEAF_ID, 'terminal-1')], 1)) + return store + }, + install: (store: TestStore): void => { + installs.push(installServerLayoutFrames(store)) + }, + uninstallAll: (): void => { + for (const uninstall of installs.splice(0)) { + uninstall() + } + } + } +} + +/** A sender's opening snapshot with the stream epoch its records frames carry. */ +export function senderSnapshot( + sender: { + snapshot: (layouts: Record, seq?: number) => unknown + }, + layouts: Record, + seq: number +): WorkspaceLayoutChangeFrame { + sender.snapshot(layouts, seq) + return { + type: 'snapshot', + workspaces: Object.entries(layouts).map(([key, layout]) => ({ key, layout })), + seq, + epoch: 'main-epoch' + } +} diff --git a/src/renderer/src/runtime/server-layout-frames.test.ts b/src/renderer/src/runtime/server-layout-frames.test.ts new file mode 100644 index 00000000000..4a25d4ba9e1 --- /dev/null +++ b/src/renderer/src/runtime/server-layout-frames.test.ts @@ -0,0 +1,385 @@ +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import type * as FrameDrawnModule from './web-session-tabs-sync/frame-drawn-server-workspaces' +import type { AppState } from '@/store/types' +import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' +import { createStoreSessionMockApi } from '../store/slices/store-session-test-harness' +import { + _resetLayoutFramePositionForTests, + afterLayoutFrame, + beginLayoutFramePositions, + noteLayoutFramePosition +} from './layout-frame-position' +import { mainLayoutFrameSender } from './main-loaded-layout-frames.test-fixture' +import { installLayoutFrameApplyCheck } from './workspace-layout-frame-apply-check.test-fixture' +import { + CAPABLE, + ENV, + LEAF_ID, + WT, + SERVER_LAYOUT, + createFakeLayoutStreams, + createServerHarness, + mirrorSnapshot, + serverLayout, + serverTerminalTab, + senderSnapshot, + sessionTabs, + settle, + surface, + treeOf +} from './server-layout-frames.test-fixture' +import { + isFrameDrawnServerWorkspace, + resetFrameDrawnServerWorkspacesForTests +} from './web-session-tabs-sync/frame-drawn-server-workspaces' +import { resetMirroredTerminalHandlesForTests } from './web-session-tabs-sync/mirrored-terminal-handle-index' +import { resetWebSessionTabsSyncTestState } from './web-session-tabs-sync-test-harness' + +type Subscribe = (...args: never[]) => { close: () => void } +const hoisted = vi.hoisted( + (): { + owners: Map + resync: Mock<(environmentId: string, worktreeId: string) => Promise> + subscribe: Subscribe | null + } => ({ + owners: new Map(), + resync: vi.fn(), + subscribe: null + }) +) + +vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) +vi.mock('./runtime-layout-client', () => ({ + subscribeRuntimeLayout: (...args: never[]) => hoisted.subscribe!(...args) +})) +vi.mock('./web-session-tabs-sync/mirrored-workspace-owner', () => ({ + mirroredRuntimeEnvironmentIdForWorkspace: (_state: unknown, key: string) => + hoisted.owners.get(key) ?? null +})) +// Why not mock its RPC instead: vitest gives concurrent dynamic imports of a mocked module the real one. +vi.mock('./web-session-tabs-sync/frame-drawn-server-workspaces', async (importOriginal) => ({ + ...(await importOriginal()), + resyncMirroredServerWorkspace: hoisted.resync +})) + +createStoreSessionMockApi() + +const streams = createFakeLayoutStreams() +hoisted.subscribe = streams.subscribe + +const TAB_1 = toWebTerminalSurfaceTabId('host-tab-1') +const OTHER_ENV = 'web-env-2' +const OTHER_WT = 'repo::/other-worktree' +const { setServers, mirroredStore, install, uninstallAll } = createServerHarness(hoisted) + +beforeEach(() => { + hoisted.owners.clear() + hoisted.resync.mockReset().mockResolvedValue(undefined) + streams.all.length = 0 + resetWebSessionTabsSyncTestState() + resetFrameDrawnServerWorkspacesForTests() + resetMirroredTerminalHandlesForTests() + _resetLayoutFramePositionForTests() +}) +afterEach(uninstallAll) + +describe('which servers get a layout stream', () => { + it('opens one only for a reachable server whose verified status advertises layout intents', () => { + const store = mirroredStore() + install(store) + expect(streams.all).toEqual([]) + + setServers(store, [ + CAPABLE, + { environmentId: OTHER_ENV, runtimeId: 'runtime-2', capable: false } + ]) + + expect(streams.all.map((stream) => stream.target)).toEqual([ + { kind: 'environment', environmentId: ENV } + ]) + }) + + it('leaves every workspace of an older server to the mirror', async () => { + const store = mirroredStore() + install(store) + setServers(store, [{ ...CAPABLE, capable: false }]) + await settle() + + // Presence: the server is paired and connected, and its workspace mirrored. + expect(store.getState().runtimeEnvironments.map((environment) => environment.id)).toEqual([ENV]) + expect(store.getState().runtimeStatusByEnvironmentId.get(ENV)?.status?.runtimeId).toBe( + 'runtime-1' + ) + expect(store.getState().tabsByWorktree[WT]?.map((tab) => tab.id)).toEqual([TAB_1]) + expect(streams.all).toEqual([]) + expect(isFrameDrawnServerWorkspace(ENV, WT)).toBe(false) + // The mirror still writes the layout: a renamed tab shows. + mirrorSnapshot( + store, + ENV, + sessionTabs([{ ...surface('host-tab-1', LEAF_ID, 'terminal-1'), title: 'renamed' }], 2) + ) + expect(store.getState().tabsByWorktree[WT]?.[0]?.title).toBe('renamed') + expect(isFrameDrawnServerWorkspace(ENV, WT)).toBe(false) + }) + + it.each([ + ['is unpaired', []], + ['disconnects', [{ ...CAPABLE, disconnected: true }]] + ] as const)( + 'closes the stream of a server that %s and hands its workspaces back (L19)', + async (_, after) => { + const store = mirroredStore() + install(store) + setServers(store, [CAPABLE]) + const stream = streams.openTo(ENV) + streams.send(stream, { + type: 'snapshot', + workspaces: [{ key: WT, layout: SERVER_LAYOUT }], + seq: 1, + epoch: 'e' + }) + await settle() + expect(isFrameDrawnServerWorkspace(ENV, WT)).toBe(true) + expect(hoisted.resync).not.toHaveBeenCalled() + + setServers(store, [...after]) + + expect(stream.closed).toBe(true) + expect(isFrameDrawnServerWorkspace(ENV, WT)).toBe(false) + await settle() + expect(hoisted.resync.mock.calls).toEqual([[ENV, WT]]) + } + ) + + it.each([ + ['a new runtime id', { runtimeId: 'runtime-1b' }], + ['a new pairing revision', { pairingRevision: 2 }] + ] as const)('replaces the stream on %s without handing back first', async (_, change) => { + const store = mirroredStore() + install(store) + setServers(store, [CAPABLE]) + const first = streams.openTo(ENV) + streams.send(first, { + type: 'snapshot', + workspaces: [{ key: WT, layout: SERVER_LAYOUT }], + seq: 1, + epoch: 'e' + }) + await settle() + expect(isFrameDrawnServerWorkspace(ENV, WT)).toBe(true) + + setServers(store, [{ ...CAPABLE, ...change }]) + await settle() + + expect(first.closed).toBe(true) + const second = streams.openTo(ENV) + expect(second).not.toBe(first) + expect(isFrameDrawnServerWorkspace(ENV, WT)).toBe(true) + expect(hoisted.resync).not.toHaveBeenCalled() + expect(store.getState().tabsByWorktree[WT]?.map((tab) => tab.id)).toEqual([TAB_1]) + }) + + it.each(['lost', 'refused'] as const)( + 'hands the workspaces back when the replacement is %s before its snapshot', + async (state) => { + const store = mirroredStore() + install(store) + setServers(store, [CAPABLE]) + streams.send(streams.openTo(ENV), { + type: 'snapshot', + workspaces: [{ key: WT, layout: SERVER_LAYOUT }], + seq: 1, + epoch: 'e' + }) + await settle() + setServers(store, [{ ...CAPABLE, runtimeId: 'runtime-1b' }]) + expect(isFrameDrawnServerWorkspace(ENV, WT)).toBe(true) + + streams + .openTo(ENV) + .onStatus(state === 'lost' ? { state } : { state, code: 'method_not_found' }) + await settle() + + expect(isFrameDrawnServerWorkspace(ENV, WT)).toBe(false) + expect(hoisted.resync.mock.calls).toEqual([[ENV, WT]]) + } + ) +}) + +describe('frames to mirror', () => { + it.each(['lost', 'refused'] as const)( + 'clears the gate and resyncs each workspace when the stream is %s, keeping the frame tree until then', + async (state) => { + const store = mirroredStore() + hoisted.owners.set(OTHER_WT, ENV) + mirrorSnapshot(store, ENV, { + ...sessionTabs([surface('host-tab-9', LEAF_ID, 'terminal-9')], 1), + worktree: OTHER_WT + }) + install(store) + setServers(store, [CAPABLE]) + const stream = streams.openTo(ENV) + streams.send(stream, { + type: 'snapshot', + workspaces: [ + { key: WT, layout: SERVER_LAYOUT }, + { + key: OTHER_WT, + layout: { + ...serverLayout([serverTerminalTab('host-tab-9', LEAF_ID, 'server-pty-9')]), + worktreeId: OTHER_WT + } + } + ], + seq: 1, + epoch: 'e' + }) + await settle() + expect([ + isFrameDrawnServerWorkspace(ENV, WT), + isFrameDrawnServerWorkspace(ENV, OTHER_WT) + ]).toEqual([true, true]) + const frameTree = treeOf(store.getState(), WT) + expect(frameTree.tabIds).toEqual([TAB_1]) + // The resync never answers here: whatever the store shows meanwhile is the frames'. + hoisted.resync.mockReturnValue(new Promise(() => {})) + const states: AppState[] = [] + const stop = store.subscribe((next) => states.push(next)) + + stream.onStatus(state === 'lost' ? { state } : { state, code: 'forbidden' }) + await settle() + stop() + + expect([ + isFrameDrawnServerWorkspace(ENV, WT), + isFrameDrawnServerWorkspace(ENV, OTHER_WT) + ]).toEqual([false, false]) + expect(hoisted.resync.mock.calls).toEqual([ + [ENV, WT], + [ENV, OTHER_WT] + ]) + expect(treeOf(store.getState(), WT)).toEqual(frameTree) + for (const recorded of states) { + expect(treeOf(recorded, WT)).toEqual(frameTree) + } + } + ) +}) + +describe('a workspace that turns out to be the server’s after its snapshot', () => { + it('stays the mirror’s until routing names its owner, then draws from frames until the stream stops', async () => { + const store = mirroredStore() + hoisted.owners.delete(WT) + install(store) + setServers(store, [CAPABLE]) + const check = installLayoutFrameApplyCheck() + try { + const stream = streams.openTo(ENV) + const frames = mainLayoutFrameSender() + const before = store.getState() + const recorded: AppState[] = [] + const stop = store.subscribe((state) => recorded.push(state)) + + streams.send(stream, senderSnapshot(frames, { [WT]: SERVER_LAYOUT }, 1)) + await settle() + stop() + + // Presence: the snapshot carried the workspace, and the mirror drew it. + expect(treeOf(before, WT).tabIds).toEqual([TAB_1]) + expect(isFrameDrawnServerWorkspace(ENV, WT)).toBe(false) + expect(recorded).toEqual([]) + expect(check.fullBuilt).toEqual([]) + + hoisted.owners.set(WT, ENV) + // A routing slice moves, as pairing does when it names the workspace's owner. + store.setState({ repos: [...store.getState().repos] }) + expect(isFrameDrawnServerWorkspace(ENV, WT)).toBe(true) + await settle() + expect(check.fullBuilt).toEqual([WT]) + + const renamed = structuredClone(SERVER_LAYOUT) + renamed.tabs[0]!.customTitle = 'renamed on the server' + streams.send(stream, frames.records(WT, renamed)) + await settle() + expect(store.getState().tabsByWorktree[WT]?.[0]?.customTitle).toBe('renamed on the server') + + stream.onStatus({ state: 'lost' }) + await settle() + expect(isFrameDrawnServerWorkspace(ENV, WT)).toBe(false) + expect(hoisted.resync.mock.calls).toEqual([[ENV, WT]]) + // The owner still reads ENV: a routing change after the loss must not take it back. + expect(hoisted.owners.get(WT)).toBe(ENV) + store.setState({ repos: [...store.getState().repos] }) + await settle() + expect(isFrameDrawnServerWorkspace(ENV, WT)).toBe(false) + expect(check.failures).toEqual([]) + } finally { + check.uninstall() + } + }) +}) + +describe('a workspace whose owner briefly reads unknown', () => { + it('is drawn from frames in full again when it rejoins the gate', async () => { + const store = mirroredStore() + install(store) + setServers(store, [CAPABLE]) + const check = installLayoutFrameApplyCheck() + try { + const stream = streams.openTo(ENV) + streams.send(stream, senderSnapshot(mainLayoutFrameSender(), { [WT]: SERVER_LAYOUT }, 1)) + await settle() + expect(check.fullBuilt).toEqual([WT]) + + hoisted.owners.delete(WT) + store.setState({ repos: [...store.getState().repos] }) + expect(isFrameDrawnServerWorkspace(ENV, WT)).toBe(false) + hoisted.owners.set(WT, ENV) + store.setState({ repos: [...store.getState().repos] }) + expect(isFrameDrawnServerWorkspace(ENV, WT)).toBe(true) + await settle() + + // The mirror may have drawn it in the gap: the frames redraw it rather than trust it. + expect(check.fullBuilt).toEqual([WT, WT]) + expect(check.failures).toEqual([]) + } finally { + check.uninstall() + } + }) +}) + +describe('server frames and this window’s layout.apply positions', () => { + it('never release a local wait, whatever their seq, nor end it when the server stream stops', async () => { + const store = mirroredStore() + install(store) + setServers(store, [CAPABLE]) + beginLayoutFramePositions() + const waiter = vi.fn() + afterLayoutFrame(3, waiter) + const stream = streams.openTo(ENV) + // Presence: the stream reports positions, as the real client does. + expect(stream.onPosition).toBeTypeOf('function') + const frames = mainLayoutFrameSender() + + streams.send(stream, senderSnapshot(frames, { [WT]: SERVER_LAYOUT }, 5)) + const renamed = structuredClone(SERVER_LAYOUT) + renamed.tabs[0]!.customTitle = 'renamed' + streams.send(stream, frames.records(WT, renamed, 9)) + await settle() + // Presence: the frames did show. + expect(store.getState().tabsByWorktree[WT]?.[0]?.customTitle).toBe('renamed') + stream.onStatus({ state: 'lost' }) + await settle() + expect(waiter).not.toHaveBeenCalled() + + noteLayoutFramePosition(3) + expect(waiter).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/runtime/server-layout-frames.ts b/src/renderer/src/runtime/server-layout-frames.ts new file mode 100644 index 00000000000..25ba6f68012 --- /dev/null +++ b/src/renderer/src/runtime/server-layout-frames.ts @@ -0,0 +1,231 @@ +// A paired server whose main takes layout intents (`layout.intents.v1`) draws its workspaces in +// this window from its layout stream instead of the session.tabs mirror (design H5). One source per +// workspace at a time: the mirror stops writing a workspace's layout as the stream's snapshot comes +// in, and gets it back, with a fresh snapshot, when the stream stops. session.tabs keeps flowing for +// terminal handles and statuses either way. + +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import { lastVerifiedRuntimeStatus } from '../../../shared/runtime-host-status' +import { LAYOUT_INTENTS_RUNTIME_CAPABILITY } from '../../../shared/workspace-layout/layout-intents-capability' +import { + isDisconnectedRuntimeHostState, + runtimeHostConnectionStateForEntry +} from './runtime-host-connection-state' +import { createServerLayoutFrameTranslator } from './server-layout-frame-ids' +import { openWorkspaceLayoutFrameSync } from './workspace-layout-frame-sync' +import { mirroredRuntimeEnvironmentIdForWorkspace } from './web-session-tabs-sync/mirrored-workspace-owner' +import { + isFrameDrawnServerWorkspace, + mirroredTerminalHandle, + onMirroredTerminalHandlesChanged, + resyncMirroredServerWorkspace, + setFrameDrawnServerWorkspaces +} from './web-session-tabs-sync/frame-drawn-server-workspaces' + +type FramesStore = { + getState: () => AppState + setState: (patch: Partial) => void + subscribe: (listener: (state: AppState) => void) => () => void +} + +type ServerFrames = { + /** Hands the server's workspaces back to the mirror unless a replacement stream takes them. */ + close: (handBack: boolean) => void + /** The workspaces the mirror leaves to this stream now. */ + gated: () => ReadonlySet +} + +/** Workspaces this server's stream draws: ones the window holds that it mirrors from this server. */ +function serverDraws(environmentId: string): (state: AppState) => (key: string) => boolean { + return (state) => (key) => + (key in state.groupsByWorktree || key in state.unifiedTabsByWorktree) && + mirroredRuntimeEnvironmentIdForWorkspace(state, key) === environmentId +} + +function openServerFrames( + store: FramesStore, + environmentId: string, + /** A replaced stream's workspaces: this one hands them back if it never takes them over. */ + inherited: ReadonlySet +): ServerFrames { + const translator = createServerLayoutFrameTranslator( + environmentId, + (worktreeId) => (hostTabId, leafId) => + mirroredTerminalHandle(environmentId, worktreeId, hostTabId, leafId) + ) + const owns = (key: string): boolean => + mirroredRuntimeEnvironmentIdForWorkspace(store.getState(), key) === environmentId + let gated = inherited + /** From the stream's snapshot until it stops: only then do its frames draw. */ + let live = false + /** Gates `keys`; a workspace leaving the gate that is still this server's returns to the mirror. */ + const gate = (keys: ReadonlySet): void => { + const returned = [...gated].filter((key) => !keys.has(key) && owns(key)) + gated = keys + setFrameDrawnServerWorkspaces(environmentId, keys) + for (const key of returned) { + // The frame tree stays until the fresh snapshot replaces it: never an empty tree between. + resyncMirroredServerWorkspace(environmentId, key).catch((error: unknown) => { + console.warn('[layout-owner] a server workspace could not return to the mirror:', error) + }) + } + } + /** Gates the workspaces the stream holds that are this server's; returns the ones newly gated. */ + const regate = (): string[] => { + const keys = new Set(translator.keys().filter(owns)) + const added = [...keys].filter((key) => !gated.has(key)) + if (added.length > 0 || keys.size !== gated.size) { + gate(keys) + } + return added + } + const handBack = (): void => { + live = false + gate(new Set()) + } + const draws = serverDraws(environmentId) + + const sync = openWorkspaceLayoutFrameSync(store, { + target: { kind: 'environment', environmentId }, + // Only gated workspaces: a handed-back one is the mirror's, whatever the frozen frames still hold. + draws: (state) => { + const ours = draws(state) + return (key) => gated.has(key) && ours(key) + }, + translate: (frame) => { + const translated = translator.translate(frame) + live ||= frame.type === 'snapshot' + // Before the frame shows: the mirror must not write a workspace the frames now draw. + regate() + return translated + }, + onStatus: (status) => { + if (status.state === 'lost' || status.state === 'refused') { + handBack() + } + } + }) + // A workspace can turn out to be this server's after its snapshot (pairing just finished): it is + // gated, then drawn, as the routing that names it arrives. + let routing: unknown[] = [] + const stopRouting = store.subscribe((state) => { + const next = routingInputs(state) + if (sameValues(next, routing)) { + return + } + routing = next + const added = live ? regate() : [] + if (added.length > 0) { + // The mirror may have drawn them meanwhile. + sync.redraw(added) + } + }) + const stopHandles = onMirroredTerminalHandlesChanged((environment, worktreeId) => { + const frame = environment === environmentId ? translator.rebind(worktreeId) : null + if (frame) { + sync.receive(frame) + } + }) + sync.start() + return { + gated: () => gated, + close: (returnWorkspaces) => { + stopRouting() + stopHandles() + sync.close() + if (returnWorkspaces) { + handBack() + } + } + } +} + +/** Opens one stream per reachable server advertising the capability; reopens on a new runtime or + * pairing, and closes when the server is removed or stops advertising it. */ +export function installServerLayoutFrames(store: FramesStore): () => void { + const open = new Map() + let inputs: unknown[] = [] + const reconcile = (state: AppState): void => { + const next = [state.runtimeEnvironments, state.runtimeStatusByEnvironmentId] + if (sameValues(next, inputs)) { + return + } + inputs = next + const wanted = new Map() + for (const environment of state.runtimeEnvironments) { + const entry = state.runtimeStatusByEnvironmentId.get(environment.id) + const status = lastVerifiedRuntimeStatus(entry) + // Why every paired server, not the focused one: each draws only the workspaces it owns. + if ( + status?.capabilities?.includes(LAYOUT_INTENTS_RUNTIME_CAPABILITY) && + !isDisconnectedRuntimeHostState(runtimeHostConnectionStateForEntry(entry)) + ) { + wanted.set( + environment.id, + `${status.runtimeId}|${environment.pairingRevision ?? environment.createdAt}|${entry?.connectionGeneration ?? 0}` + ) + } + } + const replaced = new Map>() + for (const [environmentId, entry] of open) { + if (wanted.get(environmentId) !== entry.identity) { + open.delete(environmentId) + // A replacement stream's snapshot takes the workspaces over; anything else returns them. + const replacing = wanted.has(environmentId) + entry.frames.close(!replacing) + if (replacing) { + replaced.set(environmentId, entry.frames.gated()) + } + } + } + for (const [environmentId, identity] of wanted) { + if (!open.has(environmentId)) { + const frames = openServerFrames( + store, + environmentId, + replaced.get(environmentId) ?? new Set() + ) + open.set(environmentId, { identity, frames }) + } + } + } + reconcile(store.getState()) + const stop = store.subscribe(reconcile) + return () => { + stop() + for (const { frames } of open.values()) { + frames.close(true) + } + open.clear() + } +} + +/** The store slices a workspace's owning server derives from. */ +function routingInputs(state: AppState): unknown[] { + return [ + state.repos, + state.worktreesByRepo, + state.detectedWorktreesByRepo, + state.projectGroups, + state.restoredRuntimeHostIdByWorkspaceSessionKey, + state.runtimeEnvironments, + state.runtimeStatusByEnvironmentId + ] +} + +/** Compared by reference: store slices are replaced, never edited in place. */ +function sameValues(a: readonly unknown[], b: readonly unknown[]): boolean { + return a.length === b.length && a.every((value, index) => value === b[index]) +} + +let installed: (() => void) | null = null + +/** After restore: each capable paired server's workspaces draw from its stream from now on. */ +export function showServerLayoutFrames(): void { + // Kept for the document's life: a re-run boot reuses the streams already open. + installed ??= installServerLayoutFrames(useAppStore) + if (import.meta.env.MODE === 'e2e') { + window.__isFrameDrawnServerWorkspaceE2E = isFrameDrawnServerWorkspace + } +} diff --git a/src/renderer/src/runtime/web-runtime-session-snapshot.ts b/src/renderer/src/runtime/web-runtime-session-snapshot.ts index 5d9899bcea4..1a5d87b5c4d 100644 --- a/src/renderer/src/runtime/web-runtime-session-snapshot.ts +++ b/src/renderer/src/runtime/web-runtime-session-snapshot.ts @@ -19,7 +19,7 @@ import { recordReceivedWebSessionTabsSnapshot, shouldApplyRecoveredWebSessionTabsSnapshot } from './web-session-tabs-sync/tracking' -import { recoverWebSessionTerminalOrphansBeforeApply } from './web-session-terminal-orphan-recovery' +import { recoverMirroredTerminalOrphansBeforeApply } from './web-session-tabs-sync/mirror-terminal-orphan-recovery' const pendingRuntimeWorktreeRecoveryRefreshes = new Map() const RUNTIME_WORKTREE_RECOVERY_REFRESH_DELAYS_MS = [250, 500, 1_000, 2_000, 4_000] as const @@ -116,7 +116,7 @@ export async function refreshWebRuntimeSessionTabsSnapshot( if (getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentPairingRevision) { return } - const recovered = await recoverWebSessionTerminalOrphansBeforeApply( + const recovered = await recoverMirroredTerminalOrphansBeforeApply( useAppStore.getState(), snapshot, environmentId, diff --git a/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts b/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts index 369a586410e..40f4fbf229e 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts @@ -2,9 +2,11 @@ import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' import { isRuntimeSubscriptionReplayResponse } from '../../../../shared/runtime-subscription-replay' import { useAppStore } from '../../store' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { isFrameDrawnServerWorkspace } from './frame-drawn-server-workspaces' import { mirroredRuntimeEnvironmentIdForWorkspace } from './mirrored-workspace-owner' import { getRuntimeEnvironmentRevision } from '../runtime-environment-revision' -import { recoverWebSessionTerminalOrphansBeforeApply } from '../web-session-terminal-orphan-recovery' +import { recoverMirroredTerminalOrphansBeforeApply } from './mirror-terminal-orphan-recovery' import { installWindowVisibilitySubscriptionParking, type WindowVisibilitySubscriptionParking @@ -108,7 +110,7 @@ export function installActiveSessionTabsSubscription({ receivedFrame: number, runtimeId?: string ): Promise => { - const recovered = await recoverWebSessionTerminalOrphansBeforeApply( + const recovered = await recoverMirroredTerminalOrphansBeforeApply( useAppStore.getState(), event, environmentId, @@ -150,7 +152,10 @@ export function installActiveSessionTabsSubscription({ snapshotIsFresh: decision.apply, localTerminalCount, hasLiveLocalPty, - skipWakeRespawn: shouldSkipWebRuntimeWakeTerminalRespawn(activeWorktreeId) + skipWakeRespawn: + shouldSkipWebRuntimeWakeTerminalRespawn(activeWorktreeId) || + // Switch point: ORCA_LAYOUT_OWNER_IS_MAIN && — a frame-drawn workspace's terminals are not the mirror's to respawn. + (ORCA_LAYOUT_OWNER_IS_MAIN && isFrameDrawnServerWorkspace(environmentId, activeWorktreeId)) }) let settle: HostSessionMirrorSettle | null = decision.apply ? null diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-snapshot.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-snapshot.ts index 41013a8f323..d4b362e0b3a 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/apply-snapshot.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-snapshot.ts @@ -1,4 +1,5 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' import type { WebSessionTabsBatchContext, @@ -15,6 +16,9 @@ import { applyBrowserRecordUpdates } from './apply-browser-records' import { applyWorktreeRecordUpdates } from './apply-worktree-records' import { applyActiveStateUpdates } from './apply-active-state' import { buildWebSessionTabsFinalPatch } from './apply-final-patch' +import { isFrameDrawnServerWorkspace } from './frame-drawn-server-workspaces' +import { buildFrameDrawnWorkspaceStatusPatch } from './frame-drawn-workspace-status-patch' +import { indexMirroredTerminalSurfaces } from './mirrored-terminal-handle-index' /** Reconcile one host frame through the staged terminal/browser/layout pipeline. */ export function applyWebSessionTabsSnapshotWithContext( @@ -32,17 +36,39 @@ export function applyWebSessionTabsSnapshotWithContext( return state } const worktreeId = rawSnapshot.worktree + // Switch point: ORCA_LAYOUT_OWNER_IS_MAIN && — every mirror write (single, batch, operations) passes here. + const previousSnapshotTabIds = + ORCA_LAYOUT_OWNER_IS_MAIN && options?.contentScope !== 'agent-session' + ? indexMirroredTerminalSurfaces(environmentId, rawSnapshot) + : null + const frameDrawnPreviousTabIds = + previousSnapshotTabIds && isFrameDrawnServerWorkspace(environmentId, worktreeId) + ? previousSnapshotTabIds + : null + // A frame-drawn workspace still runs these stages for their intent and id-mapping ledgers; unbatched + // so their discarded record copies never reach the batch's shared state. const base = prepareWebSessionTabsSnapshotBase( state, rawSnapshot, environmentId, worktreeId, now, - batchContext, + frameDrawnPreviousTabIds ? undefined : batchContext, options ) const browser = prepareWebSessionTabsSnapshotBrowser(base) const unified = prepareWebSessionTabsSnapshotUnified(browser) + if (frameDrawnPreviousTabIds) { + return buildFrameDrawnWorkspaceStatusPatch( + state, + base.snapshot, + environmentId, + worktreeId, + frameDrawnPreviousTabIds, + now, + batchContext + ) + } const groups = prepareWebSessionTabsSnapshotGroups(unified) const terminalRecords = applyTerminalRecordUpdates(groups) const browserRecords = applyBrowserRecordUpdates(terminalRecords) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/frame-drawn-server-workspaces.test.ts b/src/renderer/src/runtime/web-session-tabs-sync/frame-drawn-server-workspaces.test.ts new file mode 100644 index 00000000000..40e6cb97ef7 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/frame-drawn-server-workspaces.test.ts @@ -0,0 +1,292 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { toWebTerminalSurfaceTabId } from '../../../../shared/terminal-surface-id' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { + ENV, + LEAF_ID, + NOW, + SECOND_LEAF_ID, + WT, + makeSnapshot, + makeState, + resetWebSessionTabsSyncTestState +} from '../web-session-tabs-sync-test-harness' +import { applyWebSessionTabsSnapshot, applyWebSessionTabsSnapshots } from './snapshot-api' +import { decideWebSessionTabsSnapshot } from './tracking-decisions' +import type { WebSessionTabsSyncState } from './state' +import { + clearHostSessionTabIdMappings, + resolveHostSessionTabIdForWebSessionTab +} from './tracking-mappings' +import { + isWebSessionCloseIntentPending, + recordWebSessionCloseIntent +} from '../web-session-close-intent' +import { peekWebSessionFocusIntent, recordWebSessionFocusIntent } from '../web-session-focus-intent' +import { + isFrameDrawnServerWorkspace, + mirroredTerminalHandle, + onMirroredTerminalHandlesChanged, + resetFrameDrawnServerWorkspacesForTests, + resyncMirroredServerWorkspace, + setFrameDrawnServerWorkspaces +} from './frame-drawn-server-workspaces' + +const hoisted = vi.hoisted(() => ({ + layoutOwnerIsMain: true, + refresh: vi.fn<(environmentId: string, worktreeId: string) => Promise>() +})) + +vi.mock('../../../../shared/layout-owner-switch', () => ({ + get ORCA_LAYOUT_OWNER_IS_MAIN() { + return hoisted.layoutOwnerIsMain + } +})) +vi.mock('../../store', () => ({ useAppStore: { setState: vi.fn() } })) +vi.mock('../web-runtime-session-snapshot', () => ({ + refreshWebRuntimeSessionTabsSnapshot: hoisted.refresh +})) + +const OTHER_WT = 'repo::/other-worktree' +const MIRRORED_TAB_ID = toWebTerminalSurfaceTabId('host-tab-1') +const MIRRORED_PANE_KEY = makePaneKey(MIRRORED_TAB_ID, LEAF_ID) + +const LAYOUT_SLICES = [ + 'tabsByWorktree', + 'unifiedTabsByWorktree', + 'groupsByWorktree', + 'layoutByWorktree', + 'tabBarOrderByWorktree', + 'terminalLayoutsByTabId', + 'ptyIdsByTabId', + 'openFiles', + 'editorBuffers', + 'browserTabsByWorktree', + 'browserPagesByWorkspace', + 'remoteBrowserPageHandlesByPageId', + 'activeTabIdByWorktree', + 'activeGroupIdByWorktree', + 'activeFileIdByWorktree', + 'activeTabTypeByWorktree', + 'activeTabId', + 'activeFileId', + 'activeTabType', + 'pendingStartupByTabId', + 'automaticAgentResumeClaimsByTabId' +] as const + +type Surface = { hostTabId: string; leafId: string; title: string; terminal: string | null } + +function terminalSurface({ hostTabId, leafId, title, terminal }: Surface, working = false) { + const base = { + type: 'terminal' as const, + id: `${hostTabId}::${leafId}`, + title, + parentTabId: hostTabId, + leafId, + isActive: true, + ...(working + ? { + agentStatus: { + state: 'working' as const, + prompt: 'keep going', + updatedAt: NOW - 100, + stateStartedAt: NOW - 1_000, + agentType: 'codex' as const, + paneKey: makePaneKey(hostTabId, leafId), + tabId: hostTabId, + worktreeId: WT, + terminalTitle: title, + stateHistory: [] + } + } + : {}) + } + return terminal === null + ? { ...base, status: 'pending-handle' as const, terminal: null } + : { ...base, status: 'ready' as const, terminal } +} + +function shell(title: string, version: number, working = false, worktree = WT) { + return makeSnapshot( + [ + terminalSurface( + { hostTabId: 'host-tab-1', leafId: LEAF_ID, title, terminal: 'terminal-1' }, + working + ) + ], + { worktree, snapshotVersion: version } + ) +} + +function merge( + state: WebSessionTabsSyncState, + patch: WebSessionTabsSyncState | Partial +): WebSessionTabsSyncState { + return { ...state, ...patch } +} + +/** A store whose WT tree is the frames' own drawing (same ids, its own title). */ +function frameDrawnStore(): WebSessionTabsSyncState { + const mirrored = merge( + makeState(), + applyWebSessionTabsSnapshot(makeState(), shell('a', 1), ENV, NOW) + ) + const frameTab = { ...mirrored.tabsByWorktree[WT]![0]!, title: 'drawn by frames' } + setFrameDrawnServerWorkspaces(ENV, new Set([WT])) + return { ...mirrored, tabsByWorktree: { ...mirrored.tabsByWorktree, [WT]: [frameTab] } } +} + +const flushMicrotasks = () => new Promise((resolve) => queueMicrotask(resolve)) + +describe('frame-drawn server workspaces', () => { + beforeEach(() => { + hoisted.layoutOwnerIsMain = true + hoisted.refresh.mockReset() + resetWebSessionTabsSyncTestState() + resetFrameDrawnServerWorkspacesForTests() + }) + + it('applies agent status but writes no layout slice for a frame-drawn workspace', () => { + const store = frameDrawnStore() + const patch = applyWebSessionTabsSnapshot(store, shell('b', 2, true), ENV, NOW) + + expect(patch).not.toBe(store) + for (const slice of LAYOUT_SLICES) { + expect(Object.hasOwn(patch, slice), slice).toBe(false) + } + expect(patch.agentStatusByPaneKey?.[MIRRORED_PANE_KEY]).toMatchObject({ + state: 'working', + tabId: MIRRORED_TAB_ID, + worktreeId: WT + }) + }) + + it('still mirrors every slice for another workspace of the same environment, batched', () => { + const store = frameDrawnStore() + const frameTree = store.tabsByWorktree[WT] + const patch = applyWebSessionTabsSnapshots( + store, + [shell('b', 2, true), shell('other', 1, false, OTHER_WT)], + ENV, + NOW + ) + + expect(patch.tabsByWorktree?.[WT]).toBe(frameTree) + expect(patch.tabsByWorktree?.[OTHER_WT]?.[0]?.title).toBe('other') + expect(patch.agentStatusByPaneKey?.[MIRRORED_PANE_KEY]?.state).toBe('working') + }) + + it('drops unread markers for a tab the host retracted', () => { + const store = { ...frameDrawnStore(), unreadTerminalTabs: { [MIRRORED_TAB_ID]: true as const } } + const patch = applyWebSessionTabsSnapshot( + store, + makeSnapshot([], { snapshotVersion: 2 }), + ENV, + NOW + ) + + expect(patch.unreadTerminalTabs).toEqual({}) + expect(Object.hasOwn(patch, 'tabsByWorktree')).toBe(false) + }) + + it('indexes ready handles from every snapshot and notifies once per changed snapshot', async () => { + const listener = vi.fn() + onMirroredTerminalHandlesChanged(listener) + const surfaces = (second: string | null, firstTerminal = 'terminal-1') => + makeSnapshot([ + terminalSurface({ + hostTabId: 'host-tab-1', + leafId: LEAF_ID, + title: 'a', + terminal: firstTerminal + }), + ...(second === 'gone' + ? [] + : [ + terminalSurface({ + hostTabId: 'host-tab-1', + leafId: SECOND_LEAF_ID, + title: 'b', + terminal: second + }) + ]) + ]) + const handle = (leafId: string) => mirroredTerminalHandle(ENV, WT, 'host-tab-1', leafId) + + applyWebSessionTabsSnapshot(makeState(), surfaces('terminal-2'), ENV, NOW) + await flushMicrotasks() + expect([handle(LEAF_ID), handle(SECOND_LEAF_ID)]).toEqual(['terminal-1', 'terminal-2']) + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenLastCalledWith(ENV, WT) + + applyWebSessionTabsSnapshot(makeState(), surfaces('terminal-2'), ENV, NOW) + await flushMicrotasks() + expect(listener).toHaveBeenCalledTimes(1) + + setFrameDrawnServerWorkspaces(ENV, new Set([WT])) + applyWebSessionTabsSnapshot(makeState(), surfaces(null), ENV, NOW) + await flushMicrotasks() + expect(handle(SECOND_LEAF_ID)).toBeNull() + expect(listener).toHaveBeenCalledTimes(2) + + applyWebSessionTabsSnapshot(makeState(), surfaces('gone', 'terminal-3'), ENV, NOW) + await flushMicrotasks() + expect([handle(LEAF_ID), handle(SECOND_LEAF_ID)]).toEqual(['terminal-3', null]) + expect(listener).toHaveBeenCalledTimes(3) + }) + + it('hands a workspace back: the snapshot replaces the frame tree in one write', async () => { + let store = frameDrawnStore() + const commits: WebSessionTabsSyncState[] = [] + const deliver = (snapshot: RuntimeMobileSessionTabsResult) => { + if (decideWebSessionTabsSnapshot(snapshot, ENV).apply) { + store = merge(store, applyWebSessionTabsSnapshot(store, snapshot, ENV, NOW)) + commits.push(store) + } + } + // A frames-era snapshot advances freshness without touching the tree. + deliver(shell('host tree', 2)) + expect(store.tabsByWorktree[WT]?.[0]?.title).toBe('drawn by frames') + hoisted.refresh.mockImplementation(async () => deliver(shell('host tree', 2))) + + await resyncMirroredServerWorkspace(ENV, WT) + + expect(isFrameDrawnServerWorkspace(ENV, WT)).toBe(false) + expect(hoisted.refresh).toHaveBeenCalledWith(ENV, WT, { errorMode: 'throw' }) + expect(commits.map((commit) => commit.tabsByWorktree[WT]?.map((tab) => tab.title))).toEqual([ + ['drawn by frames'], + ['host tree'] + ]) + }) + + it('keeps the id-mapping and intent ledgers running for a frame-drawn workspace', () => { + const store = frameDrawnStore() + clearHostSessionTabIdMappings(ENV, WT) + const owner = { environmentId: ENV } + recordWebSessionCloseIntent(owner, WT, 'host-tab-gone', NOW) + recordWebSessionFocusIntent(owner, WT, 'host-tab-1', LEAF_ID) + + // Nothing but layout changed, so the store is untouched. + expect(applyWebSessionTabsSnapshot(store, shell('b', 2), ENV, NOW)).toBe(store) + expect( + resolveHostSessionTabIdForWebSessionTab(store, { + environmentId: ENV, + worktreeId: WT, + tabId: MIRRORED_TAB_ID + }) + ).toBe('host-tab-1') + expect(isWebSessionCloseIntentPending(owner, WT, 'host-tab-gone', NOW)).toBe(false) + expect(peekWebSessionFocusIntent(owner, WT)).toBeNull() + }) + + it('changes nothing with the switch off', () => { + hoisted.layoutOwnerIsMain = false + setFrameDrawnServerWorkspaces(ENV, new Set([WT])) + const patch = applyWebSessionTabsSnapshot(makeState(), shell('a', 1), ENV, NOW) + + expect(patch.tabsByWorktree?.[WT]?.[0]?.id).toBe(MIRRORED_TAB_ID) + expect(mirroredTerminalHandle(ENV, WT, 'host-tab-1', LEAF_ID)).toBeNull() + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/frame-drawn-server-workspaces.ts b/src/renderer/src/runtime/web-session-tabs-sync/frame-drawn-server-workspaces.ts new file mode 100644 index 00000000000..23cf2000a50 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/frame-drawn-server-workspaces.ts @@ -0,0 +1,51 @@ +import { acceptReplayedWebSessionTabsSnapshot } from './state' + +export { + mirroredTerminalHandle, + onMirroredTerminalHandlesChanged +} from './mirrored-terminal-handle-index' + +// Why one source per workspace: frames and the mirror writing the same tree would fight. +const frameDrawnWorktreeIdsByEnvironment = new Map>() + +/** + * The workspaces of `environmentId` that a server's layout frames draw; replaces the previous set. + * A workspace leaving the set keeps its frame tree until `resyncMirroredServerWorkspace` replaces it. + */ +export function setFrameDrawnServerWorkspaces( + environmentId: string, + worktreeIds: ReadonlySet +): void { + if (worktreeIds.size === 0) { + frameDrawnWorktreeIdsByEnvironment.delete(environmentId) + return + } + frameDrawnWorktreeIdsByEnvironment.set(environmentId, new Set(worktreeIds)) +} + +export function isFrameDrawnServerWorkspace(environmentId: string, worktreeId: string): boolean { + return frameDrawnWorktreeIdsByEnvironment.get(environmentId)?.has(worktreeId) ?? false +} + +/** Hands a workspace back: a fresh full snapshot replaces what frames drew. Rejects when the fetch fails. */ +export async function resyncMirroredServerWorkspace( + environmentId: string, + worktreeId: string +): Promise { + const current = frameDrawnWorktreeIdsByEnvironment.get(environmentId) + if (current?.has(worktreeId)) { + setFrameDrawnServerWorkspaces( + environmentId, + new Set([...current].filter((id) => id !== worktreeId)) + ) + } + // Why lazy: keeps the RPC layer out of the apply's import graph, as the tab lifecycle module does. + const { refreshWebRuntimeSessionTabsSnapshot } = await import('../web-runtime-session-snapshot') + // Why replay: frames-era frames already advanced freshness, so the current version would read as stale. + acceptReplayedWebSessionTabsSnapshot(environmentId, worktreeId) + await refreshWebRuntimeSessionTabsSnapshot(environmentId, worktreeId, { errorMode: 'throw' }) +} + +export function resetFrameDrawnServerWorkspacesForTests(): void { + frameDrawnWorktreeIdsByEnvironment.clear() +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/frame-drawn-workspace-status-patch.ts b/src/renderer/src/runtime/web-session-tabs-sync/frame-drawn-workspace-status-patch.ts new file mode 100644 index 00000000000..df06bf41eac --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/frame-drawn-workspace-status-patch.ts @@ -0,0 +1,115 @@ +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { toWebTerminalSurfaceTabId } from '../../../../shared/terminal-surface-id' +import { buildMirroredAgentStatusPatch } from './agent-status-patch' +import { + buildRemirroredClosedTabMarkerLiftPatch, + buildRetractedMirroredTabSweepPatch +} from './agent-status-primitives' +import { collectCollidingRetractionPaneKeys } from './retraction-pane-ownership' +import { isWebSessionTabsWorktreeRemovalFrame } from './session-tabs-inventory-absence' +import type { WebSessionTabsBatchContext, WebSessionTabsSyncState } from './state' +import { writableWebSessionTabsRecord } from './state-equality-core' +import { buildMirroredTerminalTabs } from './terminal-build' +import { isMirroredTerminalSurfaceId, isTerminalSurfaceTab } from './terminal-surfaces' + +/** + * A frame-drawn workspace's share of one snapshot: agent status and unread markers only. Its tabs, + * layout, bindings and focus belong to the layout frames, so none of those slices are written. + */ +export function buildFrameDrawnWorkspaceStatusPatch( + state: WebSessionTabsSyncState, + snapshot: RuntimeMobileSessionTabsResult, + environmentId: string, + worktreeId: string, + previousSnapshotTabIds: ReadonlySet, + now: number, + batchContext?: WebSessionTabsBatchContext +): WebSessionTabsSyncState | Partial { + const currentTerminalTabs = state.tabsByWorktree[worktreeId] ?? [] + const terminalSurfaceTabs = snapshot.tabs.filter(isTerminalSurfaceTab) + const publishedTabIds = new Set( + terminalSurfaceTabs.map((surface) => toWebTerminalSurfaceTabId(surface.parentTabId)) + ) + const removalFrame = isWebSessionTabsWorktreeRemovalFrame(snapshot) + // Why both sources: frames may drop a retracted tab before or after the host's snapshot says so. + const retractedTabIds = [ + ...new Set([ + ...previousSnapshotTabIds, + ...currentTerminalTabs.map((tab) => tab.id).filter(isMirroredTerminalSurfaceId) + ]) + ].filter((tabId) => !publishedTabIds.has(tabId)) + + const retractionPaneKeys = collectCollidingRetractionPaneKeys( + state, + retractedTabIds, + environmentId, + worktreeId, + batchContext + ) + const agentStatusPatch = buildMirroredAgentStatusPatch( + state, + currentTerminalTabs, + terminalSurfaceTabs, + // Only consulted for pane bindings retained through a pruned leaf; never written. + buildMirroredTerminalTabs( + snapshot, + environmentId, + new Map(currentTerminalTabs.map((tab) => [tab.id, tab])), + state.terminalLayoutsByTabId, + state.ptyIdsByTabId, + 0, + now + ), + environmentId, + worktreeId, + new Set(removalFrame ? [] : retractedTabIds), + now, + batchContext + ) + const retiredTabs = new Set(retractedTabIds) + // The sweep requires the retired tabs to be absent; frames may still hold them, so hide them here only. + const sweepTabsByWorktree = { + ...state.tabsByWorktree, + [worktreeId]: currentTerminalTabs.filter((tab) => !retiredTabs.has(tab.id)) + } + const retractedTabSweepPatch = removalFrame + ? null + : buildRetractedMirroredTabSweepPatch( + state, + sweepTabsByWorktree, + agentStatusPatch, + retractedTabIds, + retractionPaneKeys, + batchContext + ) + const remirroredClosedTabLiftPatch = buildRemirroredClosedTabMarkerLiftPatch( + retractedTabSweepPatch?.recentlyClosedAgentStatusTabIds ?? + state.recentlyClosedAgentStatusTabIds, + publishedTabIds + ) + + let nextUnreadTerminalTabs = state.unreadTerminalTabs + for (const tabId of retractedTabIds) { + if (!nextUnreadTerminalTabs[tabId]) { + continue + } + if (nextUnreadTerminalTabs === state.unreadTerminalTabs) { + nextUnreadTerminalTabs = writableWebSessionTabsRecord( + state, + 'unreadTerminalTabs', + batchContext + ) + } + delete nextUnreadTerminalTabs[tabId] + } + + const patch: Partial = { + ...agentStatusPatch, + ...retractedTabSweepPatch, + ...remirroredClosedTabLiftPatch, + ...(nextUnreadTerminalTabs !== state.unreadTerminalTabs + ? { unreadTerminalTabs: nextUnreadTerminalTabs } + : {}) + } + return Object.keys(patch).length === 0 ? state : patch +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/global-session-events.ts b/src/renderer/src/runtime/web-session-tabs-sync/global-session-events.ts index 956ec18449c..6ea1a1f7cfe 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/global-session-events.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/global-session-events.ts @@ -2,7 +2,7 @@ import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' import { isRuntimeSubscriptionReplayResponse } from '../../../../shared/runtime-subscription-replay' import { useAppStore } from '../../store' -import { recoverWebSessionTerminalOrphansBeforeApply } from '../web-session-terminal-orphan-recovery' +import { recoverMirroredTerminalOrphansBeforeApply } from './mirror-terminal-orphan-recovery' import { recordReceivedWebSessionTabsSnapshot, sessionTabsFreshnessKey, @@ -117,7 +117,7 @@ export function handleGlobalSessionEvent(args: GlobalSessionEventArgs): void { ) } let settleHydration: HostSessionMirrorSettle | null = null - void recoverWebSessionTerminalOrphansBeforeApply(useAppStore.getState(), event, environmentId, { + void recoverMirroredTerminalOrphansBeforeApply(useAppStore.getState(), event, environmentId, { expectedEnvironmentPairingRevision, expectedRuntimeId: runtimeId, getCurrentState: () => useAppStore.getState() diff --git a/src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts b/src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts index 60f85e7ca94..d2e193ef859 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts @@ -2,7 +2,7 @@ import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' import { recheckUnconfirmedStructuredAgentLaunches } from '../../lib/structured-agent-session-launch-unconfirmed-recheck' import { useAppStore } from '../../store' -import { recoverWebSessionTerminalOrphansBeforeApply } from '../web-session-terminal-orphan-recovery' +import { recoverMirroredTerminalOrphansBeforeApply } from './mirror-terminal-orphan-recovery' import { queueAcceptedWebSessionTerminalSnapshot } from '../web-session-terminal-handle-events' import { recordReceivedWebSessionTabsInventory, @@ -88,7 +88,7 @@ export function handleGlobalSessionInventoryEvent({ event.snapshots.map((snapshot, index) => unchanged[index] ? Promise.resolve(snapshot) - : recoverWebSessionTerminalOrphansBeforeApply( + : recoverMirroredTerminalOrphansBeforeApply( useAppStore.getState(), snapshot, environmentId, diff --git a/src/renderer/src/runtime/web-session-tabs-sync/initial-terminal-seeder.ts b/src/renderer/src/runtime/web-session-tabs-sync/initial-terminal-seeder.ts index 7687fe47f9a..05ad5b3757a 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/initial-terminal-seeder.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/initial-terminal-seeder.ts @@ -1,4 +1,6 @@ import { useAppStore } from '../../store' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { isFrameDrawnServerWorkspace } from './frame-drawn-server-workspaces' import { isWebRuntimeInitialTerminalBootstrapInFlight } from '../web-runtime-initial-terminal-bootstrap' import { dispatchWebRuntimeInitialTerminalBootstrap } from '../web-runtime-initial-terminal-bootstrap-dispatch' import { shouldApplyRecoveredWebSessionTabsSnapshot } from './tracking' @@ -36,6 +38,10 @@ export function createInitialTerminalSeeder( let disposed = false const decide = (event: SessionTabsStreamEvent, firstHostAnswer: boolean): boolean => { + // Switch point: ORCA_LAYOUT_OWNER_IS_MAIN && — a frame-drawn workspace's tabs are not the mirror's to create. + if (ORCA_LAYOUT_OWNER_IS_MAIN && isFrameDrawnServerWorkspace(environmentId, worktreeId)) { + return false + } const state = useAppStore.getState() return shouldBootstrapInitialWebRuntimeTerminal({ event, diff --git a/src/renderer/src/runtime/web-session-tabs-sync/load-initial.ts b/src/renderer/src/runtime/web-session-tabs-sync/load-initial.ts index ff08d599cfb..bacf3a67a4a 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/load-initial.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/load-initial.ts @@ -2,7 +2,7 @@ import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' import { useAppStore } from '../../store' import { getRuntimeEnvironmentRevision } from '../runtime-environment-revision' -import { recoverWebSessionTerminalOrphansBeforeApply } from '../web-session-terminal-orphan-recovery' +import { recoverMirroredTerminalOrphansBeforeApply } from './mirror-terminal-orphan-recovery' import { isSessionTabsListAllResult, recordReceivedWebSessionTabsSnapshot, @@ -88,7 +88,7 @@ export function loadInitialWebSessionTabs({ ) const recovered = await Promise.all( result.snapshots.map((snapshot) => - recoverWebSessionTerminalOrphansBeforeApply( + recoverMirroredTerminalOrphansBeforeApply( useAppStore.getState(), snapshot, environmentId, diff --git a/src/renderer/src/runtime/web-session-tabs-sync/mirror-terminal-orphan-recovery.ts b/src/renderer/src/runtime/web-session-tabs-sync/mirror-terminal-orphan-recovery.ts new file mode 100644 index 00000000000..4857b23a6e6 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/mirror-terminal-orphan-recovery.ts @@ -0,0 +1,16 @@ +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { recoverWebSessionTerminalOrphansBeforeApply } from '../web-session-terminal-orphan-recovery' +import { isFrameDrawnServerWorkspace } from './frame-drawn-server-workspaces' + +/** Orphan recovery for frames the mirror reconciles; a frame-drawn workspace's terminals are not the mirror's to adopt. */ +export function recoverMirroredTerminalOrphansBeforeApply( + ...args: Parameters +): Promise { + const [, frame, environmentId] = args + // Switch point: ORCA_LAYOUT_OWNER_IS_MAIN && — recovery claims host surfaces for local tabs frames drew. + if (ORCA_LAYOUT_OWNER_IS_MAIN && isFrameDrawnServerWorkspace(environmentId, frame.worktree)) { + return Promise.resolve(frame) + } + return recoverWebSessionTerminalOrphansBeforeApply(...args) +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/mirrored-terminal-handle-index.ts b/src/renderer/src/runtime/web-session-tabs-sync/mirrored-terminal-handle-index.ts new file mode 100644 index 00000000000..da76ac15197 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/mirrored-terminal-handle-index.ts @@ -0,0 +1,150 @@ +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { toWebTerminalSurfaceTabId } from '../../../../shared/terminal-surface-id' +import { sessionTabsFreshnessKey } from './state' +import { isReadyTerminalTab, isTerminalSurfaceTab } from './terminal-surfaces' +import { isWebSessionTabsWorktreeRemovalFrame } from './session-tabs-inventory-absence' + +type WorktreeSurfaces = { + environmentId: string + worktreeId: string + /** Local ids of every terminal tab the latest snapshot published, ready or not. */ + tabIds: ReadonlySet + handlesBySurface: ReadonlyMap +} + +type HandlesChangedListener = (environmentId: string, worktreeId: string) => void + +const surfacesByWorktree = new Map() +const listeners = new Set() +const pendingNotifications = new Map() +let notificationScheduled = false + +const NO_TAB_IDS: ReadonlySet = new Set() + +function surfaceKey(hostParentTabId: string, leafId: string): string { + return `${hostParentTabId}\0${leafId}` +} + +function sameHandles(a: ReadonlyMap, b: ReadonlyMap): boolean { + if (a.size !== b.size) { + return false + } + for (const [key, handle] of a) { + if (b.get(key) !== handle) { + return false + } + } + return true +} + +// Why a microtask: snapshots are indexed inside the store updater, and listeners read the committed store. +function notifyHandlesChanged(environmentId: string, worktreeId: string): void { + pendingNotifications.set(sessionTabsFreshnessKey(environmentId, worktreeId), { + environmentId, + worktreeId + }) + if (notificationScheduled) { + return + } + notificationScheduled = true + queueMicrotask(() => { + notificationScheduled = false + const batch = [...pendingNotifications.values()] + pendingNotifications.clear() + for (const { environmentId: env, worktreeId: wt } of batch) { + for (const listener of listeners) { + try { + listener(env, wt) + } catch (error) { + console.warn('[web-session-tabs-sync] terminal handle listener failed:', error) + } + } + } + }) +} + +/** Records one snapshot's terminal surfaces and returns the tab ids the previous one published. */ +export function indexMirroredTerminalSurfaces( + environmentId: string, + snapshot: RuntimeMobileSessionTabsResult +): ReadonlySet { + const key = sessionTabsFreshnessKey(environmentId, snapshot.worktree) + const previous = surfacesByWorktree.get(key) + if (isWebSessionTabsWorktreeRemovalFrame(snapshot)) { + forgetWorktree(key, previous) + return previous?.tabIds ?? NO_TAB_IDS + } + const tabIds = new Set() + const handlesBySurface = new Map() + for (const tab of snapshot.tabs) { + if (!isTerminalSurfaceTab(tab)) { + continue + } + tabIds.add(toWebTerminalSurfaceTabId(tab.parentTabId)) + if (isReadyTerminalTab(tab)) { + handlesBySurface.set(surfaceKey(tab.parentTabId, tab.leafId), tab.terminal) + } + } + surfacesByWorktree.set(key, { + environmentId, + worktreeId: snapshot.worktree, + tabIds, + handlesBySurface + }) + if (!sameHandles(previous?.handlesBySurface ?? new Map(), handlesBySurface)) { + notifyHandlesChanged(environmentId, snapshot.worktree) + } + return previous?.tabIds ?? NO_TAB_IDS +} + +function forgetWorktree(key: string, entry: WorktreeSurfaces | undefined): void { + if (!entry) { + return + } + surfacesByWorktree.delete(key) + if (entry.handlesBySurface.size > 0) { + notifyHandlesChanged(entry.environmentId, entry.worktreeId) + } +} + +/** Drops a worktree's index, or every worktree of the environment when none is named. */ +export function forgetMirroredTerminalHandles(environmentId: string, worktreeId?: string): void { + if (worktreeId !== undefined) { + const key = sessionTabsFreshnessKey(environmentId, worktreeId) + forgetWorktree(key, surfacesByWorktree.get(key)) + return + } + for (const [key, entry] of surfacesByWorktree) { + if (entry.environmentId === environmentId) { + forgetWorktree(key, entry) + } + } +} + +/** The ready handle of a host terminal surface, from the latest session.tabs snapshot; null when none. */ +export function mirroredTerminalHandle( + environmentId: string, + worktreeId: string, + hostParentTabId: string, + leafId: string +): string | null { + return ( + surfacesByWorktree + .get(sessionTabsFreshnessKey(environmentId, worktreeId)) + ?.handlesBySurface.get(surfaceKey(hostParentTabId, leafId)) ?? null + ) +} + +/** Fires after a snapshot changed any handle in the index for that worktree. */ +export function onMirroredTerminalHandlesChanged(listener: HandlesChangedListener): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +export function resetMirroredTerminalHandlesForTests(): void { + surfacesByWorktree.clear() + listeners.clear() + pendingNotifications.clear() +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.ts b/src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.ts index d845037045b..7ff14ca6d35 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.ts @@ -26,6 +26,10 @@ import { } from '../web-runtime-initial-terminal-bootstrap' import { clearWebSessionReorderIntentsForWorktree } from '../web-session-reorder-intent' import { clearWebSessionCloseIntentsForWorktree } from '../web-session-close-intent' +import { + forgetMirroredTerminalHandles, + resetMirroredTerminalHandlesForTests +} from './mirrored-terminal-handle-index' import { forgetRetiredEpochCensuses, resetRetiredEpochCensusesForTests @@ -97,6 +101,7 @@ export function resetWebSessionTabsSnapshotFreshnessForTests(): void { sessionTabsTrackingGenerations.reset() resetWebSessionBrowserPlacementsForTests() resetRetiredEpochCensusesForTests() + resetMirroredTerminalHandlesForTests() } export function _getWebSessionTabsTrackingCountsForTest(): { @@ -149,6 +154,7 @@ export function clearWebSessionTabsTrackingForWorktree( clearHostSessionTabIdMappings(environmentId, worktreeId) clearWebSessionBrowserPlacementsForWorktree(environmentId, worktreeId) clearWebSessionTerminalPlacementsForWorktree(environmentId, worktreeId) + forgetMirroredTerminalHandles(environmentId, worktreeId) } export function clearWebSessionTabsTrackingForEnvironment(environmentId: string): void { @@ -218,6 +224,7 @@ export function clearWebSessionTabsTrackingForEnvironment(environmentId: string) clearHostMirrorHandleGapVerdictsForEnvironment(trimmedEnvironmentId) clearAllWebRuntimeWakeTerminalRespawn() clearWebRuntimeInitialTerminalBootstrapsForEnvironment(trimmedEnvironmentId) + forgetMirroredTerminalHandles(trimmedEnvironmentId) } export function getWebSessionTabsTrackingGeneration(environmentId: string): number { diff --git a/src/renderer/src/runtime/workspace-layout-frame-drawn-workspaces.ts b/src/renderer/src/runtime/workspace-layout-frame-drawn-workspaces.ts index 93a59cbbefe..e5a77789742 100644 --- a/src/renderer/src/runtime/workspace-layout-frame-drawn-workspaces.ts +++ b/src/renderer/src/runtime/workspace-layout-frame-drawn-workspaces.ts @@ -23,6 +23,7 @@ export function framesDrawWorkspace(state: AppState): (key: string) => boolean { */ export function onWorkspaceStartsDrawing( store: HeldWorkspacesStore, + draws: (state: AppState) => (key: string) => boolean, unshown: () => string[], onDrawn: () => void ): () => void { @@ -35,8 +36,7 @@ export function onWorkspaceStartsDrawing( return } held = state - const drawn = framesDrawWorkspace(state) - if (unshown().some(drawn)) { + if (unshown().some(draws(state))) { onDrawn() } }) diff --git a/src/renderer/src/runtime/workspace-layout-frame-sync.ts b/src/renderer/src/runtime/workspace-layout-frame-sync.ts index a4699b21964..43ed48f5bc8 100644 --- a/src/renderer/src/runtime/workspace-layout-frame-sync.ts +++ b/src/renderer/src/runtime/workspace-layout-frame-sync.ts @@ -18,7 +18,6 @@ import { applyLayoutFrame, EMPTY_WORKSPACE_LAYOUT_CACHE } from '@/store/workspac import type { WorkspaceLayoutCache } from '@/store/workspace-layout-cache' import { terminalLayoutsPatch } from '@/store/workspace-layout-frame-store-patch' import { - framesDrawWorkspace, layoutHoldsBoundPane, onWorkspaceStartsDrawing } from './workspace-layout-frame-drawn-workspaces' @@ -39,11 +38,6 @@ import { type PendingWorkspaceChanges } from './workspace-layout-frame-pending' import { layoutFrameApplyObserver } from './workspace-layout-frame-apply-observer' -import { - beginLayoutFramePositions, - endLayoutFramePositions, - noteLayoutFramePosition -} from './layout-frame-position' import { provideLayoutFrameTerminalPanes, type LayoutFrameTerminalQueries @@ -58,7 +52,11 @@ import { isDrawnTabHeld, onDrawnTabsRedraw } from './layout-owner-drawn-tab-holds' -import { subscribeRuntimeLayout } from './runtime-layout-client' +import { + LOCAL_LAYOUT_FRAMES, + openLayoutFrameStream, + type LayoutFrameSource +} from './layout-frame-stream' type FrameSyncStore = { getState: () => AppState @@ -70,9 +68,16 @@ export type WorkspaceLayoutFrameSync = LayoutFrameTerminalQueries & { /** Shows every workspace the frames own once the snapshot is in, then keeps them current. */ start: () => void close: () => void + /** A frame this window made from what it already holds (a paired server's new handles). */ + receive: (frame: WorkspaceLayoutChangeFrame) => void + /** These workspaces have just started drawing from frames: each takes the full build. */ + redraw: (keys: Iterable) => void } -export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLayoutFrameSync { +export function openWorkspaceLayoutFrameSync( + store: FrameSyncStore, + source: LayoutFrameSource = LOCAL_LAYOUT_FRAMES +): WorkspaceLayoutFrameSync { let cache: WorkspaceLayoutCache = EMPTY_WORKSPACE_LAYOUT_CACHE /** Workspaces the store shows from frames; any other the frames draw takes a full build. */ const shown = new Set() @@ -118,7 +123,7 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa state.terminalLayoutsByTabId, cache, paneTreeKeys, - framesDrawWorkspace(state) + source.draws(state) ) paneTreeKeys = shownTrees.waiting show(terminalLayoutsPatch(state, shownTrees.layouts)) @@ -148,7 +153,7 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa return } const state = store.getState() - const drawn = framesDrawWorkspace(state) + const drawn = source.draws(state) const rowFieldsTaken = new Map>() const holds: LayoutFrameBuildHolds = { ...FRAME_BUILD_HOLDS, @@ -255,37 +260,20 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa scheduleFlush() } - beginLayoutFramePositions() - const stream = subscribeRuntimeLayout( - { kind: 'local' }, - {}, - (frame) => { - const before = cache - cache = applyLayoutFrame(cache, frame) - noteFrame(before, frame) - if (frame.type === 'snapshot') { - snapshotReceived = true - live = started - } - scheduleFlush() - }, - (status) => { - if (status.state === 'lost' || status.state === 'refused') { - console.warn('[layout-owner] layout stream stopped:', status) - } - if (status.state === 'connecting' && !streamOpen) { - // The local stream reopens after a loss; replies wait for its frames again. - streamOpen = true - beginLayoutFramePositions() - } else if (status.state !== 'connecting' && status.state !== 'live') { - streamOpen = false - endLayoutFramePositions() - } - }, - // After the frame's flush. A held tab may still show as the window drew it, so a waiting reply - // checks the store rather than assume the frame shows. - (seq) => queueMicrotask(() => streamOpen && noteLayoutFramePosition(seq)) - ) + const receive = (frame: WorkspaceLayoutChangeFrame): void => { + const before = cache + cache = applyLayoutFrame(cache, frame) + noteFrame(before, frame) + if (frame.type === 'snapshot') { + snapshotReceived = true + live = started + } + scheduleFlush() + } + + const stream = openLayoutFrameStream(source, receive, (open) => { + streamOpen = open + }) const stopHeldTabsListener = onDrawnTabsRedraw((workspaces) => { if (streamOpen) { rebuildWorkspaces(workspaces) @@ -308,6 +296,7 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa // activation finds main's tab rather than seeding a second one. const stopHeldWorkspacesListener = onWorkspaceStartsDrawing( store, + source.draws, () => Object.keys(cache).filter((key) => !shown.has(key)), () => streamOpen && scheduleFlush() ) @@ -321,6 +310,15 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa shown.clear() flush() }, + receive, + redraw: (keys) => { + for (const key of keys) { + shown.delete(key) + } + if (streamOpen) { + scheduleFlush() + } + }, close: () => { live = false stopHeldWorkspacesListener() @@ -330,8 +328,7 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa }, // Only a pane flush draws: the same workspaces it shows. holdsTerminalPane: (worktreeId, pane) => - framesDrawWorkspace(store.getState())(worktreeId) && - layoutHoldsBoundPane(cache[worktreeId], pane), + source.draws(store.getState())(worktreeId) && layoutHoldsBoundPane(cache[worktreeId], pane), // A closed stream's cache is frozen: main may have closed the tab since. terminalTab: (worktreeId, tabId) => streamOpen diff --git a/tests/e2e/paired-desktop-server-layout-frames.spec.ts b/tests/e2e/paired-desktop-server-layout-frames.spec.ts new file mode 100644 index 00000000000..963c2fe273b --- /dev/null +++ b/tests/e2e/paired-desktop-server-layout-frames.spec.ts @@ -0,0 +1,452 @@ +/** + * A switch-on desktop paired to a switch-on `orca serve` draws the server's workspace from the + * server's layout stream (H5b), not the session.tabs mirror: its tree equals the server's own by + * tab and pane ids and order, its terminal panes bind the session.tabs handle, and both hold + * through a serve restart and a layout change made on the server's side. + * + * Run (switch-on build: ORCA_LAYOUT_OWNER=main ... --mode e2e): + * pnpm exec playwright test tests/e2e/paired-desktop-server-layout-frames.spec.ts \ + * --config tests/playwright.config.ts --project electron-headless --workers=2 + */ +import { existsSync, rmSync } from 'node:fs' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { pairedRuntimeConnectionFromOffer } from '../../src/shared/paired-runtime-connection' +import { decodePairingOffer } from '../../src/shared/pairing' +import { subscribeRemoteRuntimeRequest } from '../../src/shared/remote-runtime-client' +import { toRemoteRuntimePtyId } from '../../src/shared/remote-runtime-pty-id' +import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-types' +import { + HOST_TERMINAL_SURFACE_SEPARATOR, + toWebTerminalSurfaceTabId +} from '../../src/shared/terminal-surface-id' +import { LAYOUT_INTENTS_RUNTIME_CAPABILITY } from '../../src/shared/workspace-layout/layout-intents-capability' +import type { PublishedWorkspaceLayout } from '../../src/shared/workspace-layout/workspace-layout-published' +import { readWorkspaceLayoutStreamFrame } from '../../src/shared/workspace-layout/workspace-layout-stream-frames' +import { expect, test } from './helpers/orca-app' +import { + launchHeadlessPairedRuntimeHost, + type HeadlessPairedRuntimeHost +} from './helpers/headless-paired-runtime-host' +import { + createHostCliTerminal, + createRetentionFixtureDirectory, + writeRetentionFixture +} from './helpers/host-created-terminal-retention-oracle' +import { + launchPairedElectronClient, + type PairedElectronClient +} from './helpers/paired-electron-client' +import { readPairedPaneContent } from './helpers/paired-host-terminal' +import { leafIdsInOrder } from './helpers/workspace-layout-oracle-model' + +const LAYOUT_OWNER_BUILD = existsSync( + path.join(process.cwd(), 'out', 'main', 'ORCA_LAYOUT_OWNER_MAIN') +) + +/** Groups in order, each tab in tab order with its pane ids in tree order. */ +type Tree = { id: string; tabs: { id: string; kind: string; panes: string[] }[] }[] + +const scratch = createRetentionFixtureDirectory() +const fixturePath = writeRetentionFixture(scratch) +const sinkPath = path.join(scratch, 'server-layout-frames.log') + +test.afterAll(() => { + rmSync(scratch, { recursive: true, force: true }) +}) + +/** The server's own layout of the workspace: the first snapshot of a stream of the test's own. */ +async function readServerLayout( + host: HeadlessPairedRuntimeHost, + worktreeId: string +): Promise { + let resolveSnapshot: (value: unknown) => void = () => {} + const snapshot = new Promise((resolve) => { + resolveSnapshot = resolve + }) + const subscription = await subscribeRemoteRuntimeRequest( + pairedRuntimeConnectionFromOffer(decodePairingOffer(host.offer.pairingUrl)), + 'layout.subscribe', + { workspaces: [worktreeId] }, + 30_000, + { + onResponse: (response) => resolveSnapshot(response.ok ? response.result : null), + onError: () => resolveSnapshot(null), + onClose: () => resolveSnapshot(null) + } + ) + try { + const timeout = new Promise((resolve) => setTimeout(() => resolve(null), 10_000)) + const frame = readWorkspaceLayoutStreamFrame(await Promise.race([snapshot, timeout])) + if (frame?.type !== 'snapshot') { + return null + } + return frame.workspaces.find((entry) => entry.key === worktreeId)?.layout ?? null + } finally { + subscription.close() + } +} + +/** The server's tree under the ids the desktop gives it: a terminal tab is named by its terminal. */ +function serverTree(layout: PublishedWorkspaceLayout | null): Tree | null { + if (!layout) { + return null + } + const tabsById = new Map(layout.tabs.map((tab) => [tab.id, tab])) + return layout.groups.map((group) => ({ + id: group.id, + tabs: group.tabIds.map((tabId) => { + const tab = tabsById.get(tabId) + if (!tab) { + return { id: tabId, kind: 'missing', panes: [] } + } + const terminal = tab.kind === 'terminal' + return { + id: terminal ? toWebTerminalSurfaceTabId(tab.entityId) : tab.id, + kind: tab.kind, + panes: terminal ? leafIdsInOrder(tab.terminal?.root) : [] + } + }) + })) +} + +async function desktopTree(page: Page, worktreeId: string): Promise { + return page.evaluate((id) => { + const state = window.__store!.getState() + const tabs = state.unifiedTabsByWorktree[id] ?? [] + type Node = { type: 'leaf'; leafId: string } | { type: 'split'; first: Node; second: Node } + const leaves = (node: Node | null | undefined): string[] => + !node + ? [] + : node.type === 'leaf' + ? [node.leafId] + : [...leaves(node.first), ...leaves(node.second)] + return (state.groupsByWorktree[id] ?? []).map((group) => ({ + id: group.id, + tabs: group.tabOrder.map((tabId) => { + const tab = tabs.find((candidate) => candidate.id === tabId) + if (!tab) { + return { id: tabId, kind: 'missing', panes: [] } + } + return { + id: tab.id, + kind: tab.contentType, + panes: + tab.contentType === 'terminal' + ? leaves(state.terminalLayoutsByTabId[tab.entityId]?.root) + : [] + } + }) + })) + }, worktreeId) +} + +/** True when frames draw it; else what the desktop knows of the server, for the failure message. */ +async function isFrameDrawn( + client: PairedElectronClient, + worktreeId: string +): Promise { + return client.page.evaluate( + ({ environmentId, worktreeId }) => { + const hook = window.__isFrameDrawnServerWorkspaceE2E + if (hook?.(environmentId, worktreeId)) { + return true + } + const status = window.__store?.getState().runtimeStatusByEnvironmentId.get(environmentId) + const capabilities = status?.snapshot?.status?.capabilities ?? [] + return { + hook: hook !== undefined, + verification: status?.snapshot?.verification ?? null, + layoutIntents: capabilities.filter((entry) => entry.startsWith('layout')) + } + }, + { environmentId: client.environmentId, worktreeId } + ) +} + +/** Polls until the workspace is frame-drawn and both trees agree three reads in a row. */ +async function expectFrameDrawnTreeMatchesServer( + host: HeadlessPairedRuntimeHost, + client: PairedElectronClient, + worktreeId: string, + message: string +): Promise { + let streak = 0 + let settled: Tree | null = null + await expect + .poll( + async () => { + const [server, desktop, frameDrawn] = await Promise.all([ + readServerLayout(host, worktreeId).then(serverTree), + desktopTree(client.page, worktreeId), + isFrameDrawn(client, worktreeId) + ]) + const match = + frameDrawn === true && + server !== null && + JSON.stringify(server) === JSON.stringify(desktop) + streak = match ? streak + 1 : 0 + settled = match ? server : null + return streak >= 3 ? 'settled' : { frameDrawn, server, desktop } + }, + { timeout: 90_000, intervals: [500], message } + ) + .toBe('settled') + return settled! +} + +async function readHostHandles( + host: HeadlessPairedRuntimeHost, + worktreeId: string, + hostTabId: string +): Promise> { + const listed = await host.client.call('session.tabs.list', { + worktree: `id:${worktreeId}` + }) + return Object.fromEntries( + listed.result.tabs.flatMap((tab) => + tab.type === 'terminal' && tab.parentTabId === hostTabId + ? [[tab.leafId, tab.status === 'ready' ? tab.terminal : null]] + : [] + ) + ) +} + +/** Each pane of the tab binds `remote:` + the handle session.tabs gives its surface now. */ +async function expectPanesBoundToHandles( + host: HeadlessPairedRuntimeHost, + client: PairedElectronClient, + worktreeId: string, + hostTabId: string, + leafIds: string[] +): Promise> { + const webTabId = toWebTerminalSurfaceTabId(hostTabId) + let expected: Record = {} + await expect + .poll( + async () => { + const handles = await readHostHandles(host, worktreeId, hostTabId) + expected = Object.fromEntries( + leafIds.map((leafId) => { + const handle = handles[leafId] + return [leafId, handle ? toRemoteRuntimePtyId(handle, client.environmentId) : 'none'] + }) + ) + const bound = await client.page.evaluate( + ({ webTabId, leafIds }) => { + const state = window.__store!.getState() + const bindings = state.terminalLayoutsByTabId[webTabId]?.ptyIdsByLeafId ?? {} + const manager = window.__paneManagers?.get(webTabId) + const mounted = Object.fromEntries( + (manager?.getPanes?.() ?? []).map((pane) => [ + pane.container.dataset.leafId ?? '', + pane.container.dataset.ptyId ?? null + ]) + ) + return Object.fromEntries( + leafIds.map((leafId) => [ + leafId, + { binding: bindings[leafId] ?? null, pane: mounted[leafId] ?? null } + ]) + ) + }, + { webTabId, leafIds } + ) + return Object.fromEntries( + Object.entries(expected).map(([leafId, ptyId]) => [ + leafId, + bound[leafId]?.binding === ptyId && + bound[leafId]?.pane === ptyId && + ptyId.startsWith('remote:') + ? 'bound' + : { want: ptyId, ...bound[leafId] } + ]) + ) + }, + { timeout: 90_000, message: 'Desktop panes never bound the session.tabs handles' } + ) + .toEqual(Object.fromEntries(leafIds.map((leafId) => [leafId, 'bound']))) + return expected +} + +async function openTab(client: PairedElectronClient, worktreeId: string, webTabId: string) { + await client.page.evaluate( + ({ environmentId, worktreeId, webTabId }) => { + const state = window.__store!.getState() + state.setActiveView('terminal') + state.setActiveWorktree(worktreeId, `runtime:${environmentId}`) + state.setActiveTab(webTabId) + }, + { environmentId: client.environmentId, worktreeId, webTabId } + ) + await expect + .poll(() => client.page.evaluate((id) => window.__paneManagers?.has(id) ?? false, webTabId), { + timeout: 60_000, + message: 'The frame-drawn terminal tab never mounted' + }) + .toBe(true) +} + +/** Types into the pane showing `leafId`, through its xterm input as a user does. */ +async function typeIntoPane( + page: Page, + webTabId: string, + leafId: string, + text: string +): Promise { + await page.evaluate( + ({ webTabId, leafId }) => { + const manager = window.__paneManagers?.get(webTabId) + const pane = manager?.getPanes?.().find((entry) => entry.container.dataset.leafId === leafId) + const input = pane?.container.querySelector('.xterm-helper-textarea') + if (!pane || !(input instanceof HTMLTextAreaElement)) { + throw new Error(`pane ${leafId} has no terminal input`) + } + pane.terminal.focus() + input.focus() + }, + { webTabId, leafId } + ) + await page.keyboard.type(text) + await page.keyboard.press('Enter') +} + +async function readPaneText(page: Page, webTabId: string, leafId: string): Promise { + return page.evaluate( + ({ webTabId, leafId }) => { + const pane = window.__paneManagers + ?.get(webTabId) + ?.getPanes?.() + .find((entry) => entry.container.dataset.leafId === leafId) + return pane?.serializeAddon?.serialize?.() ?? '' + }, + { webTabId, leafId } + ) +} + +async function waitForWorktree(host: HeadlessPairedRuntimeHost, repoId: string): Promise { + let worktreeId = '' + await expect + .poll( + async () => { + const listed = await host.client.call<{ worktrees: { id: string }[] }>('worktree.list', { + repo: `id:${repoId}` + }) + worktreeId = listed.result.worktrees[0]?.id ?? '' + return worktreeId + }, + { timeout: 30_000, message: 'Serve host never listed its folder workspace' } + ) + .not.toBe('') + return worktreeId +} + +test.describe('paired desktop draws a switch-on server from its layout frames', () => { + test.skip(!LAYOUT_OWNER_BUILD, 'Needs a layout-owner build (ORCA_LAYOUT_OWNER=main)') + + test('frames draw the server workspace; handles attach; restart and server changes keep it', async ({ + testRepoPath + }, testInfo) => { + test.setTimeout(480_000) + const host = await launchHeadlessPairedRuntimeHost({ pinnedServePort: true }) + let client: PairedElectronClient | null = null + try { + const status = await host.client.call<{ capabilities?: string[] }>('status.get') + expect(status.result.capabilities ?? []).toContain(LAYOUT_INTENTS_RUNTIME_CAPABILITY) + + const added = await host.client.call<{ repo: { id: string } }>('repo.add', { + path: testRepoPath, + kind: 'folder' + }) + const worktreeId = await waitForWorktree(host, added.result.repo.id) + const created = await createHostCliTerminal( + async (method: string, params: unknown) => + (await host.client.call(method, params)).result, + worktreeId, + fixturePath, + sinkPath + ) + // A second pane, so the pane order is part of what must match. + await host.client.call('terminal.split', { terminal: created.handle, direction: 'vertical' }) + const hostTabId = created.tabId.split(HOST_TERMINAL_SURFACE_SEPARATOR)[0]! + const webTabId = toWebTerminalSurfaceTabId(hostTabId) + + client = await launchPairedElectronClient(host.offer, testInfo, 'server-layout-frames') + const desktop = client + await expect + .poll( + () => + desktop.page.evaluate( + (id) => + window.__store + ?.getState() + .allWorktrees() + .some((worktree) => worktree.id === id) ?? false, + worktreeId + ), + { timeout: 60_000, message: 'Paired desktop never listed the server workspace' } + ) + .toBe(true) + await openTab(desktop, worktreeId, webTabId) + + let leafIds: string[] = [] + await test.step('frames are the source, and the tree equals the server layout', async () => { + const tree = await expectFrameDrawnTreeMatchesServer( + host, + desktop, + worktreeId, + 'Desktop never drew the server workspace from frames with the server tree' + ) + const tab = tree.flatMap((group) => group.tabs).find((entry) => entry.id === webTabId) + expect(tab?.panes).toHaveLength(2) + expect(tab?.panes).toContain(created.leafId) + leafIds = tab!.panes + }) + + await test.step('the terminal works through the session.tabs handle', async () => { + await expectPanesBoundToHandles(host, desktop, worktreeId, hostTabId, leafIds) + await typeIntoPane(desktop.page, webTabId, created.leafId, 'H5B_BEFORE_RESTART') + await expect + .poll(() => readPaneText(desktop.page, webTabId, created.leafId), { timeout: 30_000 }) + .toContain('LINE:H5B_BEFORE_RESTART') + }) + + await test.step('after a serve restart both sides show the same tree again', async () => { + await host.restartServeProcess() + await expectFrameDrawnTreeMatchesServer( + host, + desktop, + worktreeId, + 'After the serve restart the desktop tree never matched the server from frames' + ) + // The tree, not scrollback: a forced quit can take the dev daemon, and its shell, with it. + const bound = await expectPanesBoundToHandles(host, desktop, worktreeId, hostTabId, leafIds) + expect(Object.values(bound).every((ptyId) => ptyId.startsWith('remote:'))).toBe(true) + await typeIntoPane(desktop.page, webTabId, created.leafId, 'H5B_AFTER_RESTART') + await expect + .poll(() => readPairedPaneContent(desktop.page, webTabId), { timeout: 30_000 }) + .toContain('H5B_AFTER_RESTART') + }) + + await test.step('a tab the server creates shows on the desktop from frames', async () => { + const createdOnServer = await host.client.call<{ tab: { id: string } }>( + 'session.tabs.createTerminal', + { worktree: `id:${worktreeId}`, activate: false, select: false, navigation: 'caller' } + ) + const newWebTabId = toWebTerminalSurfaceTabId( + createdOnServer.result.tab.id.split(HOST_TERMINAL_SURFACE_SEPARATOR)[0]! + ) + const tree = await expectFrameDrawnTreeMatchesServer( + host, + desktop, + worktreeId, + 'The server-created tab never showed on the desktop from frames' + ) + expect(tree.flatMap((group) => group.tabs.map((tab) => tab.id))).toContain(newWebTabId) + }) + } finally { + await client?.dispose() + await host.dispose() + } + }) +})