mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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 <noreply@anthropic.com> * 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 <e2e@test.local> Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude
E2E Test
parent
99e91c16ae
commit
b8a5c8f910
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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<object>()),
|
||||
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 } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Vendored
+2
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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<typeof setTimeout> | 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
|
||||
}
|
||||
|
||||
@@ -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<PublishedTab> & Pick<PublishedTab, 'id' | 'kind'>): 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<string, string>()
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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<TabContentType, 'terminal' | 'kept'> = {
|
||||
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<string, PublishedWorkspaceLayout>()
|
||||
const desktop = new Map<string, Translated>()
|
||||
/** 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 }
|
||||
@@ -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<string, string>
|
||||
resync: Mock<(environmentId: string, worktreeId: string) => Promise<void>>
|
||||
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<typeof AgentStatusModule>()
|
||||
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<typeof FrameDrawnModule>()),
|
||||
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<typeof installLayoutFrameApplyCheck>
|
||||
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))
|
||||
})
|
||||
})
|
||||
@@ -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<typeof surface>[],
|
||||
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<AppState>) => 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<void>((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<typeof createTestStore>
|
||||
|
||||
/** The reachable servers and workspace owners a test's mocks read, and its installed managers. */
|
||||
export function createServerHarness(mocks: { owners: Map<string, string> }) {
|
||||
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<string, PublishedWorkspaceLayout>, seq?: number) => unknown
|
||||
},
|
||||
layouts: Record<string, PublishedWorkspaceLayout>,
|
||||
seq: number
|
||||
): WorkspaceLayoutChangeFrame {
|
||||
sender.snapshot(layouts, seq)
|
||||
return {
|
||||
type: 'snapshot',
|
||||
workspaces: Object.entries(layouts).map(([key, layout]) => ({ key, layout })),
|
||||
seq,
|
||||
epoch: 'main-epoch'
|
||||
}
|
||||
}
|
||||
@@ -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<string, string>
|
||||
resync: Mock<(environmentId: string, worktreeId: string) => Promise<void>>
|
||||
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<typeof AgentStatusModule>()
|
||||
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<typeof FrameDrawnModule>()),
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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<AppState>) => 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<string>
|
||||
}
|
||||
|
||||
/** 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<string>
|
||||
): 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<string>): 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<string, { identity: string; frames: ServerFrames }>()
|
||||
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<string, string>()
|
||||
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<string, ReadonlySet<string>>()
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<string, symbol>()
|
||||
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,
|
||||
|
||||
@@ -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<HostSessionMirrorSettle | null> => {
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<void>>()
|
||||
}))
|
||||
|
||||
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>
|
||||
): 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<void>((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()
|
||||
})
|
||||
})
|
||||
@@ -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<string, ReadonlySet<string>>()
|
||||
|
||||
/**
|
||||
* 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<string>
|
||||
): 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<void> {
|
||||
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()
|
||||
}
|
||||
@@ -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<string>,
|
||||
now: number,
|
||||
batchContext?: WebSessionTabsBatchContext
|
||||
): WebSessionTabsSyncState | Partial<WebSessionTabsSyncState> {
|
||||
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<WebSessionTabsSyncState> = {
|
||||
...agentStatusPatch,
|
||||
...retractedTabSweepPatch,
|
||||
...remirroredClosedTabLiftPatch,
|
||||
...(nextUnreadTerminalTabs !== state.unreadTerminalTabs
|
||||
? { unreadTerminalTabs: nextUnreadTerminalTabs }
|
||||
: {})
|
||||
}
|
||||
return Object.keys(patch).length === 0 ? state : patch
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof recoverWebSessionTerminalOrphansBeforeApply>
|
||||
): Promise<RuntimeMobileSessionTabsResult | null> {
|
||||
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)
|
||||
}
|
||||
@@ -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<string>
|
||||
handlesBySurface: ReadonlyMap<string, string>
|
||||
}
|
||||
|
||||
type HandlesChangedListener = (environmentId: string, worktreeId: string) => void
|
||||
|
||||
const surfacesByWorktree = new Map<string, WorktreeSurfaces>()
|
||||
const listeners = new Set<HandlesChangedListener>()
|
||||
const pendingNotifications = new Map<string, { environmentId: string; worktreeId: string }>()
|
||||
let notificationScheduled = false
|
||||
|
||||
const NO_TAB_IDS: ReadonlySet<string> = new Set()
|
||||
|
||||
function surfaceKey(hostParentTabId: string, leafId: string): string {
|
||||
return `${hostParentTabId}\0${leafId}`
|
||||
}
|
||||
|
||||
function sameHandles(a: ReadonlyMap<string, string>, b: ReadonlyMap<string, string>): 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<string> {
|
||||
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<string>()
|
||||
const handlesBySurface = new Map<string, string>()
|
||||
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()
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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<string>) => 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<string>()
|
||||
@@ -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<string, Partial<TerminalTab>>()
|
||||
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
|
||||
|
||||
@@ -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<PublishedWorkspaceLayout | null> {
|
||||
let resolveSnapshot: (value: unknown) => void = () => {}
|
||||
const snapshot = new Promise<unknown>((resolve) => {
|
||||
resolveSnapshot = resolve
|
||||
})
|
||||
const subscription = await subscribeRemoteRuntimeRequest<unknown>(
|
||||
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<null>((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<Tree> {
|
||||
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<true | { hook: boolean; verification: unknown; layoutIntents: string[] }> {
|
||||
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<Tree> {
|
||||
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<Record<string, string | null>> {
|
||||
const listed = await host.client.call<RuntimeMobileSessionTabsResult>('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<Record<string, string>> {
|
||||
const webTabId = toWebTerminalSurfaceTabId(hostTabId)
|
||||
let expected: Record<string, string> = {}
|
||||
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<void> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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 <TResult>(method: string, params: unknown) =>
|
||||
(await host.client.call<TResult>(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()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user