mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
Main sends one layout update per action, with only what changed (dark, layout-owner build only) (#31109)
* A respawned terminal's pane rebinds in one write, and the window shows a binding-only frame without a rebuild (dark, layout-owner build only) Main: when the window's spawn for a restored pane finds its process gone, the spawn that holds the pane's reservation keeps the dead binding and its fresh bind swaps it (the restart swap), so the pane is never published unbound: a same-id respawn publishes no frame, a new id one. A failed spawn still retires the dead binding. Other callers and window-owned builds retire early as before. Window: a frame that differs from the shown one only in panes' terminal bindings updates the store's pane trees as a rebuild would, falling back to a rebuild for holds and shared terminals. * Dead-owner respawn tests expect the layout-owner build's one swapped bind instead of the early retire * Main publishes one records frame per layout operation, and the window resubscribes on a missed frame (dark, layout-owner build only) Main: a durable mutation (a layout.apply, the renameTabs batch, a binding write, a repair) is one operation. It names the partitions it wrote, and at its end the layout stream publishes once: one `records` frame holding only the changed records (tab rows, workspace groups, a pane tree per tab, a binding record per pane, editor files, browser tabs, removed workspaces), each keyed by main's own ids. Writes no operation scopes (window saves) still publish when their task ends. The snapshot and every records frame carry a stream epoch. Each pane's binding carries `ptyGeneration`, which rises on every binding write in this process, so a same-id respawn is one frame with one binding record. Readers beside the stream get the end of each operation and the stream's own per-partition loaded model (`workspaceLayoutReads`), so they need no second Loader pass, with or without a window. Window: the cache applies records frames to its full layouts, so the existing flush and the binding-only path are unchanged. A records frame of another epoch or out of sequence, a malformed frame, or one the cache cannot apply resubscribes at once for a fresh snapshot. Tests install main's model check, which applies each frame to what subscribers held and compares it with a fresh projection; dev builds log a mismatch, release builds run none. * E1 review fixes: keep a swapped binding, bound the window's resyncs, guard operation listeners (dark, layout-owner build only) - A spawn whose swap bind persisted no longer retires that pane's binding when a later commit step fails; a pane on an SSH host keeps the early retire, since its dead owner lives there. - The window resubscribes at once at most three times before a frame applies cleanly, then takes the loss path's backoff. A skipped frame of a newer type still advances its position. - A throwing operation listener is logged; it no longer fails a committed mutation. - Tests freeze the shared partition model a reader gets; the dev-build model check skips frames that only change live titles. Generation and model-check comments say what is true. * Keep the layout stream's backoff reset after a single resync; only a resync storm holds it * A layout stream that recovers from a resync storm reopens on the normal delay again * Move the replaced pane owner into its own module so the stable owner no longer imports its replacement (dark, layout-owner build only) --------- Co-authored-by: E2E Test <e2e@test.local>
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SessionNotFoundError } from '../daemon/daemon-errors'
|
||||
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
|
||||
import {
|
||||
installRestartHarness,
|
||||
leafId,
|
||||
paneKey,
|
||||
registerWithFakes,
|
||||
restartSpawnArgs,
|
||||
tabId
|
||||
} from './pty-pane-owner-swap.test-fixture'
|
||||
import { WorkspaceLayoutStream } from '../runtime/workspace-layout-stream'
|
||||
import { keepAbsentBindingForSwap } from './pty/ipc/spawn-absent-binding-swap'
|
||||
import type { WorkspaceLayoutStreamEvent } from '../../shared/workspace-layout/workspace-layout-stream-frames'
|
||||
|
||||
const layoutOwner = vi.hoisted(() => ({ isMain: false }))
|
||||
vi.mock('../../shared/layout-owner-switch', () => ({
|
||||
get ORCA_LAYOUT_OWNER_IS_MAIN() {
|
||||
return layoutOwner.isMain
|
||||
}
|
||||
}))
|
||||
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
|
||||
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
|
||||
vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock()))
|
||||
vi.mock('node:child_process', async (importOriginal) =>
|
||||
(await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal())
|
||||
)
|
||||
vi.mock('../opencode/hook-service', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock())
|
||||
)
|
||||
vi.mock('../mimo/hook-service', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock())
|
||||
)
|
||||
vi.mock('../agent-hooks/server', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock())
|
||||
)
|
||||
vi.mock('../pi/titlebar-extension-service', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock())
|
||||
)
|
||||
vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock()))
|
||||
vi.mock('../wsl', async (importOriginal) =>
|
||||
(await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal())
|
||||
)
|
||||
vi.mock('../telemetry/client', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock())
|
||||
)
|
||||
vi.mock('../telemetry/classify-error', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock())
|
||||
)
|
||||
vi.mock('../cli/linux-terminal-orca-cli-shim', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock())
|
||||
)
|
||||
vi.mock('../memory/pty-registry', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock())
|
||||
)
|
||||
vi.mock('../agent-hooks/migration-unsupported-pty-state', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock())
|
||||
)
|
||||
vi.mock('../codex/codex-pane-account-registry', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock())
|
||||
)
|
||||
vi.mock('../codex/codex-state-db-backfill-recovery', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock())
|
||||
)
|
||||
|
||||
type Harness = ReturnType<typeof installRestartHarness>
|
||||
type StreamStore = ReturnType<ConstructorParameters<typeof WorkspaceLayoutStream>[0]['store']>
|
||||
|
||||
/** Main's layout stream over the harness Store, as a window subscribes to it. */
|
||||
function watchLayout(store: Harness['store']): {
|
||||
events: WorkspaceLayoutStreamEvent[]
|
||||
shownPtyId: string | undefined
|
||||
} {
|
||||
const stream = new WorkspaceLayoutStream({
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stream reads only the session getters and the write listener this fake defines.
|
||||
store: () => store as unknown as StreamStore,
|
||||
homeHostId: () => 'local',
|
||||
liveTitle: () => null
|
||||
})
|
||||
const events: WorkspaceLayoutStreamEvent[] = []
|
||||
const { snapshot } = stream.subscribe((event) => events.push(event))
|
||||
const pane = snapshot
|
||||
.flatMap(({ layout }) => layout.tabs)
|
||||
.find((tab) => tab.entityId === tabId)
|
||||
?.terminal?.panes.find((candidate) => candidate.leafId === leafId)
|
||||
return { events, shownPtyId: pane?.ptyId }
|
||||
}
|
||||
|
||||
const settle = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
/** The frames as records, keeping only what each workspace change carries. */
|
||||
const recordsOf = (events: WorkspaceLayoutStreamEvent[]) =>
|
||||
events.map((event) =>
|
||||
event.type === 'records'
|
||||
? event.workspaces.map(({ key: _key, ...change }) => change)
|
||||
: `${event.type} frame`
|
||||
)
|
||||
|
||||
describe('pty:spawn for a restored pane whose process is gone', () => {
|
||||
const { handlers, mainWindow } = setupPtyIpcSuite()
|
||||
|
||||
async function respawn(
|
||||
options: Parameters<typeof installRestartHarness>[0],
|
||||
prepare: (harness: Harness) => void = () => {}
|
||||
) {
|
||||
const harness = installRestartHarness({ oldSessionAbsent: true, ...options })
|
||||
prepare(harness)
|
||||
const retire = vi.spyOn(harness.store, 'retirePtyBinding')
|
||||
registerWithFakes(mainWindow, harness.runtime, harness.store)
|
||||
const layout = watchLayout(harness.store)
|
||||
const spawned = handlers.get('pty:spawn')!(null, restartSpawnArgs())
|
||||
return { ...harness, retire, layout, spawned }
|
||||
}
|
||||
|
||||
describe('in a layout-owner build', () => {
|
||||
beforeEach(() => {
|
||||
layoutOwner.isMain = true
|
||||
})
|
||||
afterEach(() => {
|
||||
layoutOwner.isMain = false
|
||||
})
|
||||
|
||||
it('rebinds the same id in one write and publishes its one binding record', async () => {
|
||||
const { spawned, durableBindings, boundAtFreshLaunch, retire, layout } = await respawn({
|
||||
freshId: 'pty-old'
|
||||
})
|
||||
|
||||
await expect(spawned).resolves.toMatchObject({ id: 'pty-old' })
|
||||
await settle()
|
||||
expect(layout.shownPtyId).toBe('pty-old')
|
||||
expect(boundAtFreshLaunch).toEqual(['pty-old'])
|
||||
expect(durableBindings).toEqual(['pty-old'])
|
||||
expect(retire).not.toHaveBeenCalled()
|
||||
// Its generation rises, so a reader tells the fresh process from the dead one.
|
||||
expect(recordsOf(layout.events)).toEqual([
|
||||
[{ change: 'records', bindings: { set: [{ leafId, ptyId: 'pty-old', ptyGeneration: 1 }] } }]
|
||||
])
|
||||
})
|
||||
|
||||
it('swaps a new id in one write and publishes one frame', async () => {
|
||||
const { spawned, durableBindings, store, layout } = await respawn({})
|
||||
|
||||
await expect(spawned).resolves.toMatchObject({ id: 'pty-new' })
|
||||
await settle()
|
||||
expect(layout.shownPtyId).toBe('pty-old')
|
||||
expect(durableBindings).toEqual(['pty-new'])
|
||||
expect(store.getWorkspaceSession().terminalPtyIncarnationsByPaneKey).toEqual({
|
||||
[paneKey]: 'inc-new'
|
||||
})
|
||||
expect(recordsOf(layout.events)).toEqual([
|
||||
[{ change: 'records', bindings: { set: [{ leafId, ptyId: 'pty-new', ptyGeneration: 1 }] } }]
|
||||
])
|
||||
})
|
||||
|
||||
it('drops the dead incarnation when the fresh process reports none, as the retire did', async () => {
|
||||
const { spawned, durableBindings, store } = await respawn({
|
||||
freshId: 'pty-old',
|
||||
freshIncarnationId: null
|
||||
})
|
||||
|
||||
await expect(spawned).resolves.toMatchObject({ id: 'pty-old' })
|
||||
expect(durableBindings).toEqual(['pty-old'])
|
||||
expect(store.getWorkspaceSession().terminalPtyIncarnationsByPaneKey).toEqual({})
|
||||
})
|
||||
|
||||
it('keeps the swapped same-id binding when a later commit step fails', async () => {
|
||||
const { spawned, leafBinding, retire } = await respawn(
|
||||
{ freshId: 'pty-old' },
|
||||
({ runtime }) => {
|
||||
runtime.registerPty.mockImplementation(() => {
|
||||
throw new Error('register failed')
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await expect(spawned).rejects.toThrow('register failed')
|
||||
expect(retire).not.toHaveBeenCalled()
|
||||
expect(leafBinding()).toBe('pty-old')
|
||||
})
|
||||
|
||||
it('keeps the early retire for a pane on an SSH host', () => {
|
||||
const spawnOn = (connectionId?: string) =>
|
||||
keepAbsentBindingForSwap(
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the swap gate reads only args, the reservation and the replaced owner.
|
||||
{
|
||||
args: { connectionId },
|
||||
paneSpawnReservation: {},
|
||||
replacedPaneOwner: null
|
||||
} as unknown as Parameters<typeof keepAbsentBindingForSwap>[0]
|
||||
)
|
||||
expect(spawnOn()).toBeTypeOf('function')
|
||||
// Its dead owner lives on that host, which the swap does not reach.
|
||||
expect(spawnOn('ssh-conn-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('still retires the dead binding when the fresh spawn fails', async () => {
|
||||
const { spawned, leafBinding } = await respawn({}, ({ providerSpawn }) => {
|
||||
providerSpawn.mockRejectedValueOnce(new SessionNotFoundError('pty-old'))
|
||||
providerSpawn.mockRejectedValueOnce(new Error('spawn failed'))
|
||||
})
|
||||
|
||||
await expect(spawned).rejects.toThrow('spawn failed')
|
||||
expect(leafBinding()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the early retire in a window-owned build', async () => {
|
||||
const { spawned, durableBindings, boundAtFreshLaunch, retire } = await respawn({
|
||||
freshId: 'pty-old'
|
||||
})
|
||||
|
||||
await expect(spawned).resolves.toMatchObject({ id: 'pty-old' })
|
||||
expect(boundAtFreshLaunch).toEqual([undefined])
|
||||
expect(durableBindings).toEqual([undefined, 'pty-old'])
|
||||
expect(retire).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import { withDurableRuntimeStore } from '../runtime/runtime-durable-store-fixtur
|
||||
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
|
||||
import { SessionNotFoundError } from '../daemon/daemon-errors'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
|
||||
import { registerPtyHandlers } from './pty/register-handlers'
|
||||
import { setLocalPtyProvider } from './pty/provider/registry'
|
||||
import { isHiddenRendererPty } from './pty-hidden-delivery-gate'
|
||||
@@ -177,8 +178,17 @@ describe('registerPtyHandlers', () => {
|
||||
'inc-proven-absent-owner',
|
||||
{ hostExitConfirmed: true }
|
||||
)
|
||||
expect(store.setWorkspaceSession).toHaveBeenCalledOnce()
|
||||
expect(store.flushOrThrow).toHaveBeenCalledOnce()
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
// The dead binding stays until the fresh spawn's one bind swaps it.
|
||||
expect(store.setWorkspaceSession).not.toHaveBeenCalled()
|
||||
const [swap] = vi.mocked(store.persistPtyBinding).mock.calls[0] ?? []
|
||||
expect(typeof swap === 'function' ? swap() : swap).toMatchObject({
|
||||
ptyId: 'pty-proven-absent-owner'
|
||||
})
|
||||
} else {
|
||||
expect(store.setWorkspaceSession).toHaveBeenCalledOnce()
|
||||
expect(store.flushOrThrow).toHaveBeenCalledOnce()
|
||||
}
|
||||
})
|
||||
it('does not poll after the routed provider confirms absence', async () => {
|
||||
const worktreeId = 'repo-1::/tmp/probe-blip-owner'
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
// The pane-owner swap harness: a real binding store over a fake session, a fake provider, and the
|
||||
// real IPC spawn pipeline. The caller's test file declares the module mocks (see the restart test).
|
||||
import { vi, type Mock } from 'vitest'
|
||||
import { PtyBindingPersistenceOperations } from '../persistence/loading-store/pty-binding-persistence'
|
||||
import { ptyBindingGenerationOf } from '../persistence/loading-store/pty-binding-generations'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { PtyIpcSuiteFixtures } from './pty-ipc-test-harness'
|
||||
import { TerminalKilledError } from '../daemon/daemon-pty-lifecycle-errors'
|
||||
import { SessionNotFoundError } from '../daemon/daemon-errors'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import { registerPtyHandlers } from './pty/register-handlers'
|
||||
import { setLocalPtyProvider } from './pty/provider/registry'
|
||||
import { TerminalIntentionalStops } from '../runtime/terminal-intentional-stops'
|
||||
|
||||
/** A named type, so the exported harness's inferred type stays portable. */
|
||||
function stub(): Mock<(...args: unknown[]) => unknown> {
|
||||
return vi.fn()
|
||||
}
|
||||
|
||||
export const worktreeId = 'repo-1::/tmp/restart'
|
||||
export const cwd = '/tmp/restart'
|
||||
export const tabId = 'tab-restart'
|
||||
export const leafId = '12121212-1212-4212-8212-121212121212'
|
||||
export const paneKey = makePaneKey(tabId, leafId)
|
||||
|
||||
export type RestartHarness = ReturnType<typeof installRestartHarness>
|
||||
type FakeSession = {
|
||||
tabsByWorktree: Record<string, { id: string; worktreeId: string; ptyId: string | null }[]>
|
||||
terminalLayoutsByTabId: Record<
|
||||
string,
|
||||
{
|
||||
root: { type: 'leaf'; leafId: string }
|
||||
activeLeafId: string
|
||||
expandedLeafId: null
|
||||
ptyIdsByLeafId: Record<string, string>
|
||||
}
|
||||
>
|
||||
terminalPtyIncarnationsByPaneKey: Record<string, string>
|
||||
}
|
||||
|
||||
export function seedSession(ptyId: string): FakeSession {
|
||||
return {
|
||||
tabsByWorktree: { [worktreeId]: [{ id: tabId, worktreeId, ptyId }] },
|
||||
terminalLayoutsByTabId: {
|
||||
[tabId]: {
|
||||
root: { type: 'leaf', leafId },
|
||||
activeLeafId: leafId,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [leafId]: ptyId }
|
||||
}
|
||||
},
|
||||
terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-old' }
|
||||
}
|
||||
}
|
||||
|
||||
export function registerWithFakes(
|
||||
mainWindow: PtyIpcSuiteFixtures['mainWindow'],
|
||||
runtime: RestartHarness['runtime'],
|
||||
store: RestartHarness['store']
|
||||
): void {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: spawn and kill read only the window, runtime and store members these fakes define.
|
||||
const args = [
|
||||
mainWindow,
|
||||
runtime,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
store
|
||||
] as unknown as Parameters<typeof registerPtyHandlers>
|
||||
registerPtyHandlers(...args)
|
||||
}
|
||||
|
||||
export function installRestartHarness(
|
||||
options: {
|
||||
shutdownFails?: boolean
|
||||
shutdownGate?: Promise<void>
|
||||
/** The owning host answers that the old process is gone, as after a relaunch. */
|
||||
oldSessionAbsent?: boolean
|
||||
freshId?: string
|
||||
/** Null: the fresh process reports no incarnation (a provider without one). */
|
||||
freshIncarnationId?: string | null
|
||||
} = {}
|
||||
) {
|
||||
let oldSessionAlive = true
|
||||
const writeListeners = new Set<(hostId: null) => void>()
|
||||
const control = { shutdownFails: options.shutdownFails ?? false, failNextWrite: false }
|
||||
const boundAtFreshLaunch: (string | undefined)[] = []
|
||||
const providerSpawn = vi.fn(async (spawnOptions: { attachOnly?: boolean }) => {
|
||||
if (!spawnOptions.attachOnly) {
|
||||
boundAtFreshLaunch.push(leafBinding())
|
||||
const incarnationId =
|
||||
options.freshIncarnationId === undefined ? 'inc-new' : options.freshIncarnationId
|
||||
return { id: options.freshId ?? 'pty-new', ...(incarnationId ? { incarnationId } : {}) }
|
||||
}
|
||||
if (options.oldSessionAbsent) {
|
||||
throw new SessionNotFoundError('pty-old')
|
||||
}
|
||||
if (!oldSessionAlive) {
|
||||
throw new TerminalKilledError('pty-old')
|
||||
}
|
||||
return { id: 'pty-old', incarnationId: 'inc-old', isReattach: true }
|
||||
})
|
||||
const shutdown = vi.fn(async () => {
|
||||
await options.shutdownGate
|
||||
if (control.shutdownFails) {
|
||||
throw new Error('daemon unreachable')
|
||||
}
|
||||
oldSessionAlive = false
|
||||
})
|
||||
const provider = {
|
||||
spawn: providerSpawn,
|
||||
write: stub(),
|
||||
resize: stub(),
|
||||
kill: stub(),
|
||||
shutdown,
|
||||
sendSignal: stub(),
|
||||
getCwd: stub(),
|
||||
getInitialCwd: stub(),
|
||||
clearBuffer: stub(),
|
||||
acknowledgeDataEvent: stub(),
|
||||
hasChildProcesses: stub(),
|
||||
getForegroundProcess: stub(),
|
||||
serialize: stub(),
|
||||
revive: stub(),
|
||||
onData: vi.fn(() => () => {}),
|
||||
onReplay: vi.fn(() => () => {}),
|
||||
onExit: vi.fn(() => () => {}),
|
||||
listProcesses: vi.fn(async () => []),
|
||||
attach: stub(),
|
||||
getDefaultShell: stub(),
|
||||
getProfiles: stub()
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restart path calls only the provider members this fake defines.
|
||||
setLocalPtyProvider(provider as unknown as Parameters<typeof setLocalPtyProvider>[0])
|
||||
let session = seedSession('pty-old')
|
||||
const store = {
|
||||
getWorkspaceSession: vi.fn((_hostId?: string): FakeSession => session),
|
||||
setWorkspaceSession: vi.fn((next) => {
|
||||
session = next
|
||||
}),
|
||||
flushOrThrow: stub(),
|
||||
onWorkspaceSessionWritten: (listener: (hostId: null) => void) => {
|
||||
writeListeners.add(listener)
|
||||
return () => writeListeners.delete(listener)
|
||||
},
|
||||
runDurableMutation: vi.fn(
|
||||
async <T>(
|
||||
mutate: () => { value: T; persist?: boolean | 'if-dirty'; rollback?: () => void }
|
||||
) => {
|
||||
const mutation = mutate()
|
||||
if (mutation.persist !== false) {
|
||||
if (control.failNextWrite) {
|
||||
control.failNextWrite = false
|
||||
mutation.rollback?.()
|
||||
throw new Error('save failed')
|
||||
}
|
||||
durableBindings.push(leafBinding())
|
||||
for (const listener of writeListeners) {
|
||||
listener(null)
|
||||
}
|
||||
}
|
||||
return mutation.value
|
||||
}
|
||||
),
|
||||
getWorkspaceSessionHostIds: vi.fn(() => [
|
||||
'local',
|
||||
...Object.keys(state.workspaceSessionsByHostId)
|
||||
]),
|
||||
getFolderWorkspace: vi.fn(() => undefined),
|
||||
getFolderWorkspaces: vi.fn(() => []),
|
||||
getProjectGroups: vi.fn(() => []),
|
||||
getRepos: vi.fn(() => [])
|
||||
}
|
||||
const state: {
|
||||
workspaceSession: FakeSession
|
||||
workspaceSessionsByHostId: Record<string, FakeSession>
|
||||
} = { workspaceSession: session, workspaceSessionsByHostId: {} }
|
||||
const durableBindings: (string | undefined)[] = []
|
||||
function leafBinding(hostId?: string): string | undefined {
|
||||
return store.getWorkspaceSession(hostId).terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId?.[leafId]
|
||||
}
|
||||
// Like the store, a missing host partition reads as an empty session, never the local one.
|
||||
store.getWorkspaceSession.mockImplementation((hostId?: string) =>
|
||||
hostId && hostId !== 'local'
|
||||
? (state.workspaceSessionsByHostId[hostId] ?? {
|
||||
tabsByWorktree: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
terminalPtyIncarnationsByPaneKey: {}
|
||||
})
|
||||
: state.workspaceSession
|
||||
)
|
||||
const bindingRuntime = {
|
||||
state,
|
||||
dirtyProfileStateDomains: new Set(),
|
||||
runDurableMutation: store.runDurableMutation,
|
||||
writeTimer: null,
|
||||
pendingWrite: null,
|
||||
quitFlushStarted: false,
|
||||
writeGeneration: 0,
|
||||
lastDurableWriteGeneration: 0,
|
||||
ptyBindingGenerations: new Map<string, number>()
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: binding writes read only state, write bookkeeping, durable mutation and the session partitions from these fakes.
|
||||
const bindingArgs = [bindingRuntime, store] as unknown as ConstructorParameters<
|
||||
typeof PtyBindingPersistenceOperations
|
||||
>
|
||||
const bindingOperations = new PtyBindingPersistenceOperations(...bindingArgs)
|
||||
const storeWithRetirement = Object.assign(store, {
|
||||
persistPtyBinding: vi.fn(bindingOperations.persistPtyBinding.bind(bindingOperations)),
|
||||
retirePtyBinding: bindingOperations.retirePtyBinding.bind(bindingOperations),
|
||||
getPtyBindingGeneration: (hostId: ExecutionHostId, leafId: string) =>
|
||||
ptyBindingGenerationOf(bindingRuntime, hostId, leafId)
|
||||
})
|
||||
const runtime = {
|
||||
setPtyController: stub(),
|
||||
resolveTerminalPane: vi.fn(() => {
|
||||
throw new Error('terminal_not_found')
|
||||
}),
|
||||
markPtyStopRequested: stub(),
|
||||
createPreAllocatedTerminalHandle: vi.fn(() => 'term-restart'),
|
||||
preAllocateHandleForPty: vi.fn(() => 'term-restart'),
|
||||
registerPreAllocatedHandleForPty: stub(),
|
||||
beginPtyRegistration: stub(),
|
||||
cancelPendingPtyRegistration: stub(),
|
||||
assertPtyRegistrationAllowed: stub(),
|
||||
registerPty: stub(),
|
||||
noteTerminalSpawnCommand: stub(),
|
||||
seedHeadlessTerminal: stub(),
|
||||
onPtySpawned: stub(),
|
||||
onPtyExit: stub(),
|
||||
onPtyData: stub(),
|
||||
intentionalPtyStops: new TerminalIntentionalStops()
|
||||
}
|
||||
return {
|
||||
providerSpawn,
|
||||
shutdown,
|
||||
store: storeWithRetirement,
|
||||
runtime,
|
||||
control,
|
||||
provider,
|
||||
leafBinding,
|
||||
durableBindings,
|
||||
boundAtFreshLaunch,
|
||||
partitions: state.workspaceSessionsByHostId
|
||||
}
|
||||
}
|
||||
|
||||
export function restartSpawnArgs(extra: { replacesPtyId?: string } = {}) {
|
||||
return {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd,
|
||||
command: 'codex',
|
||||
launchAgent: 'codex',
|
||||
worktreeId,
|
||||
tabId,
|
||||
leafId,
|
||||
env: { ORCA_PANE_KEY: paneKey, ORCA_TAB_ID: tabId, ORCA_WORKTREE_ID: worktreeId },
|
||||
...extra
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import { PtyBindingPersistenceOperations } from '../persistence/loading-store/pty-binding-persistence'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { setupPtyIpcSuite, type PtyIpcSuiteFixtures } from './pty-ipc-test-harness'
|
||||
import { TerminalKilledError } from '../daemon/daemon-pty-lifecycle-errors'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import { registerPtyHandlers } from './pty/register-handlers'
|
||||
import { setLocalPtyProvider } from './pty/provider/registry'
|
||||
import { TerminalIntentionalStops } from '../runtime/terminal-intentional-stops'
|
||||
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
|
||||
import {
|
||||
installRestartHarness,
|
||||
leafId,
|
||||
paneKey,
|
||||
registerWithFakes,
|
||||
restartSpawnArgs,
|
||||
tabId,
|
||||
worktreeId
|
||||
} from './pty-pane-owner-swap.test-fixture'
|
||||
|
||||
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
|
||||
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
|
||||
@@ -51,225 +54,6 @@ vi.mock('../codex/codex-state-db-backfill-recovery', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock())
|
||||
)
|
||||
|
||||
const worktreeId = 'repo-1::/tmp/restart'
|
||||
const cwd = '/tmp/restart'
|
||||
const tabId = 'tab-restart'
|
||||
const leafId = '12121212-1212-4212-8212-121212121212'
|
||||
const paneKey = makePaneKey(tabId, leafId)
|
||||
|
||||
type RestartHarness = ReturnType<typeof installRestartHarness>
|
||||
type FakeSession = {
|
||||
tabsByWorktree: Record<string, { id: string; worktreeId: string; ptyId: string | null }[]>
|
||||
terminalLayoutsByTabId: Record<
|
||||
string,
|
||||
{
|
||||
root: { type: 'leaf'; leafId: string }
|
||||
activeLeafId: string
|
||||
expandedLeafId: null
|
||||
ptyIdsByLeafId: Record<string, string>
|
||||
}
|
||||
>
|
||||
terminalPtyIncarnationsByPaneKey: Record<string, string>
|
||||
}
|
||||
|
||||
function seedSession(ptyId: string): FakeSession {
|
||||
return {
|
||||
tabsByWorktree: { [worktreeId]: [{ id: tabId, worktreeId, ptyId }] },
|
||||
terminalLayoutsByTabId: {
|
||||
[tabId]: {
|
||||
root: { type: 'leaf', leafId },
|
||||
activeLeafId: leafId,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [leafId]: ptyId }
|
||||
}
|
||||
},
|
||||
terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-old' }
|
||||
}
|
||||
}
|
||||
|
||||
function registerWithFakes(
|
||||
mainWindow: PtyIpcSuiteFixtures['mainWindow'],
|
||||
runtime: RestartHarness['runtime'],
|
||||
store: RestartHarness['store']
|
||||
): void {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: spawn and kill read only the window, runtime and store members these fakes define.
|
||||
const args = [
|
||||
mainWindow,
|
||||
runtime,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
store
|
||||
] as unknown as Parameters<typeof registerPtyHandlers>
|
||||
registerPtyHandlers(...args)
|
||||
}
|
||||
|
||||
function installRestartHarness(
|
||||
options: { shutdownFails?: boolean; shutdownGate?: Promise<void> } = {}
|
||||
) {
|
||||
let oldSessionAlive = true
|
||||
const control = { shutdownFails: options.shutdownFails ?? false, failNextWrite: false }
|
||||
const boundAtFreshLaunch: (string | undefined)[] = []
|
||||
const providerSpawn = vi.fn(async (spawnOptions: { attachOnly?: boolean }) => {
|
||||
if (!spawnOptions.attachOnly) {
|
||||
boundAtFreshLaunch.push(leafBinding())
|
||||
return { id: 'pty-new', incarnationId: 'inc-new' }
|
||||
}
|
||||
if (!oldSessionAlive) {
|
||||
throw new TerminalKilledError('pty-old')
|
||||
}
|
||||
return { id: 'pty-old', incarnationId: 'inc-old', isReattach: true }
|
||||
})
|
||||
const shutdown = vi.fn(async () => {
|
||||
await options.shutdownGate
|
||||
if (control.shutdownFails) {
|
||||
throw new Error('daemon unreachable')
|
||||
}
|
||||
oldSessionAlive = false
|
||||
})
|
||||
const provider = {
|
||||
spawn: providerSpawn,
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
shutdown,
|
||||
sendSignal: vi.fn(),
|
||||
getCwd: vi.fn(),
|
||||
getInitialCwd: vi.fn(),
|
||||
clearBuffer: vi.fn(),
|
||||
acknowledgeDataEvent: vi.fn(),
|
||||
hasChildProcesses: vi.fn(),
|
||||
getForegroundProcess: vi.fn(),
|
||||
serialize: vi.fn(),
|
||||
revive: vi.fn(),
|
||||
onData: vi.fn(() => () => {}),
|
||||
onReplay: vi.fn(() => () => {}),
|
||||
onExit: vi.fn(() => () => {}),
|
||||
listProcesses: vi.fn(async () => []),
|
||||
attach: vi.fn(),
|
||||
getDefaultShell: vi.fn(),
|
||||
getProfiles: vi.fn()
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restart path calls only the provider members this fake defines.
|
||||
setLocalPtyProvider(provider as unknown as Parameters<typeof setLocalPtyProvider>[0])
|
||||
let session = seedSession('pty-old')
|
||||
const store = {
|
||||
getWorkspaceSession: vi.fn((_hostId?: string): FakeSession => session),
|
||||
setWorkspaceSession: vi.fn((next) => {
|
||||
session = next
|
||||
}),
|
||||
flushOrThrow: vi.fn(),
|
||||
runDurableMutation: vi.fn(
|
||||
async <T>(
|
||||
mutate: () => { value: T; persist?: boolean | 'if-dirty'; rollback?: () => void }
|
||||
) => {
|
||||
const mutation = mutate()
|
||||
if (mutation.persist !== false) {
|
||||
if (control.failNextWrite) {
|
||||
control.failNextWrite = false
|
||||
mutation.rollback?.()
|
||||
throw new Error('save failed')
|
||||
}
|
||||
durableBindings.push(leafBinding())
|
||||
}
|
||||
return mutation.value
|
||||
}
|
||||
),
|
||||
getWorkspaceSessionHostIds: vi.fn(() => [
|
||||
'local',
|
||||
...Object.keys(state.workspaceSessionsByHostId)
|
||||
]),
|
||||
getFolderWorkspace: vi.fn(() => undefined),
|
||||
getFolderWorkspaces: vi.fn(() => []),
|
||||
getProjectGroups: vi.fn(() => []),
|
||||
getRepos: vi.fn(() => [])
|
||||
}
|
||||
const state: {
|
||||
workspaceSession: FakeSession
|
||||
workspaceSessionsByHostId: Record<string, FakeSession>
|
||||
} = { workspaceSession: session, workspaceSessionsByHostId: {} }
|
||||
const durableBindings: (string | undefined)[] = []
|
||||
function leafBinding(hostId?: string): string | undefined {
|
||||
return store.getWorkspaceSession(hostId).terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId?.[leafId]
|
||||
}
|
||||
// Like the store, a missing host partition reads as an empty session, never the local one.
|
||||
store.getWorkspaceSession.mockImplementation((hostId?: string) =>
|
||||
hostId && hostId !== 'local'
|
||||
? (state.workspaceSessionsByHostId[hostId] ?? {
|
||||
tabsByWorktree: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
terminalPtyIncarnationsByPaneKey: {}
|
||||
})
|
||||
: state.workspaceSession
|
||||
)
|
||||
const bindingRuntime = {
|
||||
state,
|
||||
dirtyProfileStateDomains: new Set(),
|
||||
runDurableMutation: store.runDurableMutation,
|
||||
writeTimer: null,
|
||||
pendingWrite: null,
|
||||
quitFlushStarted: false,
|
||||
writeGeneration: 0,
|
||||
lastDurableWriteGeneration: 0
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: binding writes read only state, write bookkeeping, durable mutation and the session partitions from these fakes.
|
||||
const bindingArgs = [bindingRuntime, store] as unknown as ConstructorParameters<
|
||||
typeof PtyBindingPersistenceOperations
|
||||
>
|
||||
const bindingOperations = new PtyBindingPersistenceOperations(...bindingArgs)
|
||||
const storeWithRetirement = Object.assign(store, {
|
||||
persistPtyBinding: vi.fn(bindingOperations.persistPtyBinding.bind(bindingOperations)),
|
||||
retirePtyBinding: bindingOperations.retirePtyBinding.bind(bindingOperations)
|
||||
})
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
resolveTerminalPane: vi.fn(() => {
|
||||
throw new Error('terminal_not_found')
|
||||
}),
|
||||
markPtyStopRequested: vi.fn(),
|
||||
createPreAllocatedTerminalHandle: vi.fn(() => 'term-restart'),
|
||||
preAllocateHandleForPty: vi.fn(() => 'term-restart'),
|
||||
registerPreAllocatedHandleForPty: vi.fn(),
|
||||
beginPtyRegistration: vi.fn(),
|
||||
cancelPendingPtyRegistration: vi.fn(),
|
||||
assertPtyRegistrationAllowed: vi.fn(),
|
||||
registerPty: vi.fn(),
|
||||
noteTerminalSpawnCommand: vi.fn(),
|
||||
seedHeadlessTerminal: vi.fn(),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn(),
|
||||
intentionalPtyStops: new TerminalIntentionalStops()
|
||||
}
|
||||
return {
|
||||
providerSpawn,
|
||||
shutdown,
|
||||
store: storeWithRetirement,
|
||||
runtime,
|
||||
control,
|
||||
provider,
|
||||
leafBinding,
|
||||
durableBindings,
|
||||
boundAtFreshLaunch,
|
||||
partitions: state.workspaceSessionsByHostId
|
||||
}
|
||||
}
|
||||
|
||||
function restartSpawnArgs(extra: { replacesPtyId?: string } = {}) {
|
||||
return {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd,
|
||||
command: 'codex',
|
||||
launchAgent: 'codex',
|
||||
worktreeId,
|
||||
tabId,
|
||||
leafId,
|
||||
env: { ORCA_PANE_KEY: paneKey, ORCA_TAB_ID: tabId, ORCA_WORKTREE_ID: worktreeId },
|
||||
...extra
|
||||
}
|
||||
}
|
||||
|
||||
describe('pty:spawn replacing a pane owner', () => {
|
||||
const { handlers, mainWindow } = setupPtyIpcSuite()
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { statSyncMock } from './pty-ipc-mock-registry'
|
||||
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
|
||||
import { SessionNotFoundError, TerminalSessionOwnerUnverifiedError } from '../daemon/daemon-errors'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
|
||||
import { registerPtyHandlers } from './pty/register-handlers'
|
||||
import { clearProviderPtyState } from './pty/provider/state-cleanup'
|
||||
import { setLocalPtyProvider } from './pty/provider/registry'
|
||||
@@ -375,8 +376,17 @@ describe('registerPtyHandlers', () => {
|
||||
expect(providerSpawn.mock.calls[1]?.[0]).toMatchObject({
|
||||
command: 'codex resume exact-dead-provider-session'
|
||||
})
|
||||
expect(store.setWorkspaceSession).toHaveBeenCalledOnce()
|
||||
expect(store.flushOrThrow).toHaveBeenCalledOnce()
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
// The dead binding stays until the fresh spawn's one bind swaps it.
|
||||
expect(store.setWorkspaceSession).not.toHaveBeenCalled()
|
||||
const [swap] = vi.mocked(store.persistPtyBinding).mock.calls[0] ?? []
|
||||
expect(typeof swap === 'function' ? swap() : swap).toMatchObject({
|
||||
ptyId: 'pty-fresh-recovery'
|
||||
})
|
||||
} else {
|
||||
expect(store.setWorkspaceSession).toHaveBeenCalledOnce()
|
||||
expect(store.flushOrThrow).toHaveBeenCalledOnce()
|
||||
}
|
||||
expect(runtime.onPtyExit).toHaveBeenCalledWith(
|
||||
'pty-dead-persisted-owner',
|
||||
-1,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
|
||||
import type { ReplacedPaneOwner } from '../pane/replaced-pane-owner'
|
||||
import type { PtyIpcSpawnState } from './spawn-state'
|
||||
|
||||
/**
|
||||
* Switch point: a gone process's binding stays until this spawn's bind swaps it, one write, so the
|
||||
* pane is never published unbound. The held reservation keeps any other spawn off the pane meanwhile.
|
||||
* A pane on an SSH host keeps the early retire: its dead owner lives on that host.
|
||||
*/
|
||||
export function keepAbsentBindingForSwap(
|
||||
ctx: PtyIpcSpawnState
|
||||
): ((dead: ReplacedPaneOwner) => void) | undefined {
|
||||
if (
|
||||
!ORCA_LAYOUT_OWNER_IS_MAIN ||
|
||||
!ctx.paneSpawnReservation ||
|
||||
ctx.replacedPaneOwner ||
|
||||
ctx.args.connectionId
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return (dead) => {
|
||||
ctx.replacedPaneOwner = dead
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
pendingRuntimePaneCreatesByOwnerKey
|
||||
} from '../pane/spawn-reservation'
|
||||
import { resolveStablePaneOwner } from '../pane/stable-owner'
|
||||
import { excludeReplacedPaneOwner } from '../pane/pane-owner-replacement'
|
||||
import { excludeReplacedPaneOwner } from '../pane/replaced-pane-owner'
|
||||
import type { PtyIpcSpawnState } from './spawn-state'
|
||||
import type { PtySpawnIpcArgs } from './spawn-types'
|
||||
|
||||
|
||||
@@ -66,6 +66,8 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<P
|
||||
if (persisted === false) {
|
||||
throw new Error('terminal_pane_owner_changed')
|
||||
}
|
||||
// The swap holds the live binding now: a later failure must not retire it under the same id.
|
||||
ctx.replacedPaneOwner = null
|
||||
} catch (err) {
|
||||
console.error('[pty] failed to persist PTY binding after spawn:', err)
|
||||
await discardUnpersistedPtySpawn(ctx.provider, ctx.result, ctx.deps.runtime)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { ensureWslHookRelayForReattach } from '../../../agent-hooks/wsl-hook-relay-reattach'
|
||||
import { noteWslDistrosRunning } from '../../../wsl-background-distro-gate'
|
||||
import { resolveStablePaneOwner, spawnForStablePane } from '../pane/stable-owner'
|
||||
import { excludeReplacedPaneOwner } from '../pane/pane-owner-replacement'
|
||||
import { excludeReplacedPaneOwner } from '../pane/replaced-pane-owner'
|
||||
import { assertSpawnReplyWasLive } from '../pane/agent-session-owners'
|
||||
import { clearProviderPtyState } from '../provider/state-cleanup'
|
||||
import { failPtySpawnExecute } from '../spawn-execute-failure'
|
||||
import type { PtyIpcSpawnState } from './spawn-state'
|
||||
import { keepAbsentBindingForSwap } from './spawn-absent-binding-swap'
|
||||
|
||||
export async function executePtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<void> {
|
||||
const args = ctx.args
|
||||
@@ -56,7 +57,8 @@ export async function executePtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<void> {
|
||||
owner: stablePaneOwnerCandidate,
|
||||
worktreeId: args.worktreeId,
|
||||
connectionId: args.connectionId,
|
||||
resolveOwner
|
||||
resolveOwner,
|
||||
keepAbsentBinding: keepAbsentBindingForSwap(ctx)
|
||||
})
|
||||
ctx.result = stablePaneSpawn.result
|
||||
ctx.stablePaneOwner = stablePaneSpawn.owner
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { getProvider } from '../provider/registry'
|
||||
import { resolvePtySpawnStartupTarget } from '../spawn-startup-target'
|
||||
import type { PtyIpcSpawnState } from './spawn-state'
|
||||
import { keepAbsentBindingForSwap } from './spawn-absent-binding-swap'
|
||||
import { getConnectionExecutionHostId } from '../../../../shared/execution-host'
|
||||
|
||||
export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promise<void> {
|
||||
@@ -47,7 +48,8 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis
|
||||
worktreeId: ctx.earlyWorktreeId,
|
||||
tabId: ctx.earlyStablePaneOwner.tabId,
|
||||
leafId: ctx.earlyStablePaneOwner.leafId,
|
||||
ownsPaneSpawnReservation: true
|
||||
ownsPaneSpawnReservation: true,
|
||||
keepAbsentBinding: keepAbsentBindingForSwap(ctx)
|
||||
})
|
||||
: null
|
||||
ctx.spawnTiming.mark('stable_adoption')
|
||||
|
||||
@@ -12,7 +12,7 @@ import { createPtySpawnTiming } from '../../pty-spawn-timing'
|
||||
import { noCodexResumeLaunch, type CodexResumeLaunch } from '../host-env/codex-resume'
|
||||
import type { StablePaneOwner } from '../pane/stable-owner'
|
||||
import type { PaneSpawnReservation } from '../pane/spawn-reservation'
|
||||
import type { ReplacedPaneOwner } from '../pane/pane-owner-replacement'
|
||||
import type { ReplacedPaneOwner } from '../pane/replaced-pane-owner'
|
||||
import { localProvider } from '../provider/registry'
|
||||
import type { AdoptStablePaneResult, PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types'
|
||||
import type { PaneAgentLaunch } from './spawn-agent-launch-end'
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
PrepareCodexSessionResume
|
||||
} from '../host-env/types'
|
||||
import type { StablePaneOwner } from '../pane/stable-owner'
|
||||
import type { ReplacedPaneOwner } from '../pane/replaced-pane-owner'
|
||||
import type { ResolvedPtyHost } from '../provider/registry'
|
||||
import type { PtySpawnCodexResumeDeps } from '../spawn-host-env'
|
||||
|
||||
@@ -76,6 +77,8 @@ export type AdoptStablePaneArgs = {
|
||||
tabId: string
|
||||
leafId: string
|
||||
ownsPaneSpawnReservation?: true
|
||||
/** See StablePaneSpawnContext; only with `ownsPaneSpawnReservation`. */
|
||||
keepAbsentBinding?: (dead: ReplacedPaneOwner) => void
|
||||
}
|
||||
|
||||
export type AdoptStablePaneResult = {
|
||||
|
||||
@@ -5,22 +5,21 @@ import type { OrcaRuntimeService } from '../../../runtime/orca-runtime'
|
||||
import { ptyOwnership } from '../provider/ownership-state'
|
||||
import { clearProviderPtyState } from '../provider/state-cleanup'
|
||||
import { retireReplacedPaneBinding } from './pane-owner-replacement'
|
||||
import type { ReplacedPaneOwner } from './replaced-pane-owner'
|
||||
|
||||
/**
|
||||
* The owning host answered that the pane's process is gone. The pane outlives it: a fresh spawn
|
||||
* replaces the process, so only the dead id's binding is cleared. False when another owner took
|
||||
* the pane meanwhile.
|
||||
*/
|
||||
export async function releaseAbsentPaneOwner(
|
||||
context: {
|
||||
runtime: OrcaRuntimeService | undefined
|
||||
store?: Store
|
||||
worktreeId?: string
|
||||
connectionId?: string | null
|
||||
},
|
||||
owner: { tabId: string; leafId: string; ptyId: string; incarnationId?: string }
|
||||
): Promise<boolean> {
|
||||
const { runtime, store, worktreeId, connectionId } = context
|
||||
type AbsentPaneContext = {
|
||||
runtime: OrcaRuntimeService | undefined
|
||||
store?: Store
|
||||
worktreeId?: string
|
||||
connectionId?: string | null
|
||||
}
|
||||
type AbsentPaneOwner = { tabId: string; leafId: string; ptyId: string; incarnationId?: string }
|
||||
|
||||
/** Ends the dead process's in-memory state; its durable binding is the caller's to clear. */
|
||||
function releaseAbsentPaneProcess(
|
||||
runtime: OrcaRuntimeService | undefined,
|
||||
owner: AbsentPaneOwner
|
||||
): void {
|
||||
const settleStop = runtime?.intentionalPtyStops?.mark(
|
||||
owner.ptyId,
|
||||
'replaced',
|
||||
@@ -32,14 +31,14 @@ export async function releaseAbsentPaneOwner(
|
||||
settleStop?.(true)
|
||||
clearProviderPtyState(owner.ptyId)
|
||||
ptyOwnership.delete(owner.ptyId)
|
||||
if (!worktreeId) {
|
||||
return true
|
||||
}
|
||||
// Without the Store the binding cannot be cleared, so the pane is not free for a fresh spawn.
|
||||
if (!store) {
|
||||
return false
|
||||
}
|
||||
return retireReplacedPaneBinding(store, {
|
||||
}
|
||||
|
||||
function absentPaneReplacement(
|
||||
worktreeId: string,
|
||||
connectionId: string | null | undefined,
|
||||
owner: AbsentPaneOwner
|
||||
): ReplacedPaneOwner & { pane: NonNullable<ReplacedPaneOwner['pane']> } {
|
||||
return {
|
||||
ptyId: owner.ptyId,
|
||||
pane: {
|
||||
worktreeId,
|
||||
@@ -47,5 +46,42 @@ export async function releaseAbsentPaneOwner(
|
||||
leafId: owner.leafId,
|
||||
hostId: connectionId ? toSshExecutionHostId(connectionId) : undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The owning host answered that the pane's process is gone. The pane outlives it: a fresh spawn
|
||||
* replaces the process, so only the dead id's binding is cleared. False when another owner took
|
||||
* the pane meanwhile.
|
||||
*/
|
||||
export async function releaseAbsentPaneOwner(
|
||||
context: AbsentPaneContext,
|
||||
owner: AbsentPaneOwner
|
||||
): Promise<boolean> {
|
||||
const { runtime, store, worktreeId, connectionId } = context
|
||||
releaseAbsentPaneProcess(runtime, owner)
|
||||
if (!worktreeId) {
|
||||
return true
|
||||
}
|
||||
// Without the Store the binding cannot be cleared, so the pane is not free for a fresh spawn.
|
||||
if (!store) {
|
||||
return false
|
||||
}
|
||||
return retireReplacedPaneBinding(store, absentPaneReplacement(worktreeId, connectionId, owner))
|
||||
}
|
||||
|
||||
/**
|
||||
* As releaseAbsentPaneOwner, but the dead binding stays for the fresh spawn's bind to swap in one
|
||||
* write. Null when that swap cannot happen here, so the caller retires as before.
|
||||
*/
|
||||
export function keepAbsentPaneBindingForSwap(
|
||||
context: AbsentPaneContext,
|
||||
owner: AbsentPaneOwner
|
||||
): ReplacedPaneOwner | null {
|
||||
const { runtime, store, worktreeId, connectionId } = context
|
||||
if (!worktreeId || !store) {
|
||||
return null
|
||||
}
|
||||
releaseAbsentPaneProcess(runtime, owner)
|
||||
return { ...absentPaneReplacement(worktreeId, connectionId, owner), dropsIncarnation: true }
|
||||
}
|
||||
|
||||
@@ -75,7 +75,8 @@ export async function adoptStablePane(
|
||||
worktreeId: args.worktreeId,
|
||||
connectionId: args.connectionId,
|
||||
resolveOwner: () =>
|
||||
resolveStablePaneOwner(runtime, store, paneKey, args.worktreeId, args.connectionId)
|
||||
resolveStablePaneOwner(runtime, store, paneKey, args.worktreeId, args.connectionId),
|
||||
keepAbsentBinding: args.ownsPaneSpawnReservation ? args.keepAbsentBinding : undefined
|
||||
})
|
||||
if (!ownerKey) {
|
||||
return await adoption
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { fixture } from '../../../persistence/loading-store/profile-state-delayed-authority-fixture'
|
||||
import { TEST_LEAF_1, TEST_LEAF_2 } from '../../../persistence-session-fixtures'
|
||||
import { swapReplacedPaneBinding, type ReplacedPaneOwner } from './pane-owner-replacement'
|
||||
import { swapReplacedPaneBinding } from './pane-owner-replacement'
|
||||
import type { ReplacedPaneOwner } from './replaced-pane-owner'
|
||||
|
||||
vi.mock('../../../telemetry/client', () => ({ track: vi.fn() }))
|
||||
vi.mock('../../../telemetry/cohort-classifier', () => ({
|
||||
|
||||
@@ -3,13 +3,8 @@ import { parsePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import type { Store } from '../../../persistence'
|
||||
import type { PersistPtyBindingArgs } from '../../../persistence/loading-store/pty-binding-persistence'
|
||||
import type { OrcaRuntimeService } from '../../../runtime/orca-runtime'
|
||||
import { resolveStablePaneOwner, type StablePaneOwner } from './stable-owner'
|
||||
|
||||
/** The owner a replacing spawn stopped. Its binding stays until the replacement's bind swaps it. */
|
||||
export type ReplacedPaneOwner = {
|
||||
ptyId: string
|
||||
pane: { worktreeId: string; tabId: string; leafId: string; hostId: string | undefined } | null
|
||||
}
|
||||
import type { ReplacedPaneOwner } from './replaced-pane-owner'
|
||||
import { resolveStablePaneOwner } from './stable-owner'
|
||||
|
||||
/** Caller holds the pane's spawn reservation, so no other spawn can claim the pane meanwhile. */
|
||||
export async function stopReplacedPaneOwner(
|
||||
@@ -51,14 +46,6 @@ export async function stopReplacedPaneOwner(
|
||||
}
|
||||
}
|
||||
|
||||
/** The stopped id is dead whatever its incarnation, so a binding naming it must not be reattached. */
|
||||
export function excludeReplacedPaneOwner(
|
||||
owner: StablePaneOwner | null,
|
||||
replaced: ReplacedPaneOwner | null
|
||||
): StablePaneOwner | null {
|
||||
return owner && owner.ptyId === replaced?.ptyId ? null : owner
|
||||
}
|
||||
|
||||
/**
|
||||
* The replacement's bind is the swap: decided inside the bind's durable mutation, it takes a leaf
|
||||
* still naming the stopped id, or one the renderer already cleared; any other owner wins.
|
||||
@@ -74,7 +61,10 @@ export function swapReplacedPaneBinding(
|
||||
binding.leafId
|
||||
]
|
||||
// Unbound is ok: the renderer's pre-connect clear can withdraw it (split tab).
|
||||
return bound === undefined || bound === replaced.ptyId ? binding : null
|
||||
if (bound !== undefined && bound !== replaced.ptyId) {
|
||||
return null
|
||||
}
|
||||
return replaced.dropsIncarnation ? { ...binding, dropsIncarnation: true } : binding
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/** The owner a replacing spawn stopped. Its binding stays until the replacement's bind swaps it. */
|
||||
export type ReplacedPaneOwner = {
|
||||
ptyId: string
|
||||
pane: { worktreeId: string; tabId: string; leafId: string; hostId: string | undefined } | null
|
||||
/** A gone process's: its swap clears the incarnation as the retire it stands in for would. */
|
||||
dropsIncarnation?: true
|
||||
}
|
||||
|
||||
/** The stopped id is dead whatever its incarnation, so a binding naming it must not be reattached. */
|
||||
export function excludeReplacedPaneOwner<Owner extends { ptyId: string }>(
|
||||
owner: Owner | null,
|
||||
replaced: ReplacedPaneOwner | null
|
||||
): Owner | null {
|
||||
return owner && owner.ptyId === replaced?.ptyId ? null : owner
|
||||
}
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
TerminalSessionOwnerUnverifiedError
|
||||
} from '../../../daemon/daemon-errors'
|
||||
import { ptyIncarnationById } from '../provider/ownership-state'
|
||||
import { releaseAbsentPaneOwner } from './absent-pane-owner'
|
||||
import { keepAbsentPaneBindingForSwap, releaseAbsentPaneOwner } from './absent-pane-owner'
|
||||
import { excludeReplacedPaneOwner, type ReplacedPaneOwner } from './replaced-pane-owner'
|
||||
import { spawnCommitBindingOrigin } from '../../../persistence/loading-store/pty-binding-span'
|
||||
|
||||
export type StablePaneOwner = {
|
||||
@@ -179,6 +180,8 @@ export type StablePaneSpawnContext = {
|
||||
connectionId?: string | null
|
||||
resolveOwner?: () => StablePaneOwner | null
|
||||
onFreshSpawn?: (result: PtySpawnResult) => void
|
||||
/** Only a spawn holding the pane's reservation: it binds next, so its bind swaps the dead id. */
|
||||
keepAbsentBinding?: (dead: ReplacedPaneOwner) => void
|
||||
}
|
||||
|
||||
export function stablePanePersistenceFence(
|
||||
@@ -263,10 +266,13 @@ export async function attachStablePaneOwner(
|
||||
) {
|
||||
throw new Error('terminal_pane_owner_changed')
|
||||
}
|
||||
if (!(await releaseAbsentPaneOwner(args, owner))) {
|
||||
const kept = args.keepAbsentBinding ? keepAbsentPaneBindingForSwap(args, owner) : null
|
||||
if (kept) {
|
||||
args.keepAbsentBinding?.(kept)
|
||||
} else if (!(await releaseAbsentPaneOwner(args, owner))) {
|
||||
throw new Error('terminal_pane_owner_changed')
|
||||
}
|
||||
if (args.resolveOwner?.()) {
|
||||
if (excludeReplacedPaneOwner(args.resolveOwner?.() ?? null, kept)) {
|
||||
throw new Error('terminal_pane_owner_changed')
|
||||
}
|
||||
return null
|
||||
|
||||
@@ -208,6 +208,39 @@ describe('stable pane adoption after the owning host reports the PTY absent', ()
|
||||
expect(session.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId?.[LEAF]).toBeUndefined()
|
||||
})
|
||||
|
||||
// A spawn holding the pane's reservation binds next: its bind swaps the dead id in one write.
|
||||
it('keeps the absent binding for the swap when the spawn takes it', async () => {
|
||||
const { store, read } = sessionStore([LEAF, SIBLING_LEAF])
|
||||
const before = JSON.stringify(read())
|
||||
const spawn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new SessionNotFoundError(OWNER.ptyId))
|
||||
.mockResolvedValueOnce({ id: 'ssh:conn-1@@pty-3', isReattach: false })
|
||||
const keepAbsentBinding = vi.fn()
|
||||
|
||||
const result = await spawnForStablePane({
|
||||
runtime: undefined,
|
||||
store,
|
||||
worktreeId: WORKTREE,
|
||||
provider: { spawn } as unknown as IPtyProvider,
|
||||
spawnOptions: { cols: 80, rows: 24 },
|
||||
owner: OWNER,
|
||||
connectionId: 'conn-1',
|
||||
// The persisted owner is still the dead id until the bind swaps it.
|
||||
resolveOwner: () => OWNER,
|
||||
keepAbsentBinding
|
||||
})
|
||||
|
||||
expect(result.owner).toBeNull()
|
||||
expect(spawn).toHaveBeenCalledTimes(2)
|
||||
expect(keepAbsentBinding).toHaveBeenCalledExactlyOnceWith({
|
||||
ptyId: OWNER.ptyId,
|
||||
pane: { worktreeId: WORKTREE, tabId: 'tab-1', leafId: LEAF, hostId: 'ssh:conn-1' },
|
||||
dropsIncarnation: true
|
||||
})
|
||||
expect(JSON.stringify(read())).toBe(before)
|
||||
})
|
||||
|
||||
it('leaves persistence untouched when the failure is not positive absence', async () => {
|
||||
const { store, read } = sessionStore([LEAF, SIBLING_LEAF])
|
||||
const before = JSON.stringify(read())
|
||||
|
||||
@@ -14,7 +14,7 @@ import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultWorkspaceSession } from '../sh
|
||||
import { folderWorkspaceKey } from '../shared/workspace-scope'
|
||||
import type { WorkspaceSessionState } from '../shared/workspace-session-state-types'
|
||||
import type { DetectedWorktreeListResult } from '../shared/worktree/types'
|
||||
import type { WorkspaceLayoutEvent } from '../shared/workspace-layout/workspace-layout-stream-frames'
|
||||
import { collectLayoutEvents } from './runtime/workspace-layout-stream-events.test-fixture'
|
||||
import {
|
||||
bumpLocalWorktreeScanGeneration,
|
||||
getLocalWorktreeCatalogVersion
|
||||
@@ -165,8 +165,10 @@ describe('main drops the layout of workspaces the catalog no longer validates',
|
||||
homeHostId: () => 'local',
|
||||
liveTitle: () => null
|
||||
})
|
||||
const events: WorkspaceLayoutEvent[] = []
|
||||
const { snapshot } = stream.subscribe((event) => events.push(event))
|
||||
const collector = collectLayoutEvents()
|
||||
const { events } = collector
|
||||
const { snapshot } = stream.subscribe(collector.listener)
|
||||
collector.seed(snapshot)
|
||||
expect(snapshot.map((entry) => entry.key)).toContain(DELETED)
|
||||
|
||||
dropWorkspacesTheListingInvalidates(store, listing([KEPT]))
|
||||
|
||||
@@ -24,6 +24,7 @@ export type PrimaryStateWriteOperationsRuntime = Pick<
|
||||
| 'writeTimer'
|
||||
| 'writesFrozen'
|
||||
| 'workspaceSessionWriteListeners'
|
||||
| 'workspaceSessionOperationListeners'
|
||||
>
|
||||
|
||||
export function markPrimaryStateWriteDurable(
|
||||
|
||||
@@ -13,7 +13,8 @@ import { writeToDiskSync } from './primary-state-write-sync'
|
||||
import { writeProfileStateInWorker } from './primary-state-write-worker'
|
||||
import type { DurableProfileStateMutation } from './store-runtime-state'
|
||||
import { profileStateWriterFailureOutcome } from '../profile-state/profile-state-writer-errors'
|
||||
import { notifyWorkspaceSessionWritten } from './workspace-session-write-listeners'
|
||||
import { notifyWorkspaceSessionOperationEnded } from './workspace-session-write-listeners'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
|
||||
const primaryStateWriteOperationsContext = Symbol('PrimaryStateWriteOperations')
|
||||
export class PrimaryStateWriteOperations {
|
||||
@@ -63,7 +64,11 @@ export class PrimaryStateWriteOperations {
|
||||
}
|
||||
let mutation: DurableProfileStateMutation<T>
|
||||
try {
|
||||
mutation = this.runAdmittedMutationCallback('mutate', mutate)
|
||||
mutation = this.runAdmittedMutationCallback(
|
||||
'mutate',
|
||||
mutate,
|
||||
(result) => result.writtenHostIds
|
||||
)
|
||||
} catch (error) {
|
||||
await stopAfterFailedPrimaryStateMutation(runtime, error)
|
||||
throw error
|
||||
@@ -103,16 +108,26 @@ export class PrimaryStateWriteOperations {
|
||||
})
|
||||
}
|
||||
|
||||
private runAdmittedMutationCallback<T>(phase: 'mutate' | 'rollback', callback: () => T): T {
|
||||
private runAdmittedMutationCallback<T>(
|
||||
phase: 'mutate' | 'rollback',
|
||||
callback: () => T,
|
||||
/** The partitions the callback declared it wrote; a rollback's are any. */
|
||||
writtenHostIdsOf: (result: T) => readonly ExecutionHostId[] | undefined = () => undefined
|
||||
): T {
|
||||
const { runtime } = this[primaryStateWriteOperationsContext]
|
||||
// Finalization drains admitted mutations; the disk wait must not admit new snapshots.
|
||||
runtime.durableMutationPhase = phase
|
||||
let result: T | undefined
|
||||
try {
|
||||
return callback()
|
||||
result = callback()
|
||||
return result
|
||||
} finally {
|
||||
runtime.durableMutationPhase = null
|
||||
// Durable writers may edit sessions without scheduling a save; a rollback republishes.
|
||||
notifyWorkspaceSessionWritten(runtime)
|
||||
notifyWorkspaceSessionOperationEnded(
|
||||
runtime,
|
||||
result === undefined ? null : (writtenHostIdsOf(result) ?? null)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Each pane's binding generation (`LayoutLeaf.ptyGeneration`): it rises on each pty bind
|
||||
// (`persistPtyBinding` past its fast lane), retire and rollback of that pane in this process, a
|
||||
// same-id respawn included. Layout commands that move or drop a pane do not bump it. In memory
|
||||
// only; layout-owner builds only.
|
||||
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
|
||||
import type { StoreRuntimeState } from './store-runtime-state'
|
||||
|
||||
type GenerationRuntime = Pick<StoreRuntimeState, 'ptyBindingGenerations'>
|
||||
|
||||
const generationKey = (hostId: ExecutionHostId, leafId: string) => `${hostId}\0${leafId}`
|
||||
|
||||
export function bumpPtyBindingGeneration(
|
||||
runtime: GenerationRuntime,
|
||||
hostId: ExecutionHostId,
|
||||
leafId: string
|
||||
): void {
|
||||
// Switch point: a window-owned build publishes no generation, so it counts none.
|
||||
if (!ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
return
|
||||
}
|
||||
const key = generationKey(hostId, leafId)
|
||||
runtime.ptyBindingGenerations.set(key, (runtime.ptyBindingGenerations.get(key) ?? 0) + 1)
|
||||
}
|
||||
|
||||
/** Undefined for a pane whose binding this process never wrote. */
|
||||
export function ptyBindingGenerationOf(
|
||||
runtime: GenerationRuntime,
|
||||
hostId: ExecutionHostId,
|
||||
leafId: string
|
||||
): number | undefined {
|
||||
return runtime.ptyBindingGenerations.get(generationKey(hostId, leafId))
|
||||
}
|
||||
@@ -10,18 +10,22 @@ import { clearReplacedPaneBinding } from './replaced-pane-binding'
|
||||
import type { PtyBindingSourceExpectation } from './store'
|
||||
|
||||
import type { StoreRuntimeState } from './store-runtime-state'
|
||||
import type { SessionHostPartitionOperations } from './session-host-partitions'
|
||||
import { resolveHostId, type SessionHostPartitionOperations } from './session-host-partitions'
|
||||
import {
|
||||
withdrawUnstartedPane,
|
||||
type UnstartedPaneKept
|
||||
} from '../terminal-topology/unstarted-pane-withdraw'
|
||||
import { resolveHostId } from './session-host-partitions'
|
||||
import { evaluatePtyBindingFastLane } from './pty-binding-fast-lane'
|
||||
import { ptyBindingIsRefused } from './pty-binding-refusals'
|
||||
import { startPtyBindingSpan, type PtyBindingOrigin, type PtyBindingSpan } from './pty-binding-span'
|
||||
import { applyPtyBinding } from './pty-binding-session-update'
|
||||
import {
|
||||
reportPtyBindingOwnerConflict,
|
||||
reportPtyBindingPlacement,
|
||||
startPtyBindingSpan,
|
||||
type PtyBindingOrigin,
|
||||
type PtyBindingSpan
|
||||
} from './pty-binding-span'
|
||||
import type { TerminalPanePlacement } from '../../../shared/terminal-pane-placement'
|
||||
import { terminalPanePlacementAgreement } from '../terminal-topology/terminal-pane-placement-agreement'
|
||||
import type {
|
||||
TerminalLeafMoveRequest,
|
||||
TerminalLeafMoveResult
|
||||
@@ -41,7 +45,7 @@ import type {
|
||||
CommandOf,
|
||||
LayoutRefusalCode
|
||||
} from '../../../shared/workspace-layout/workspace-layout-command-types'
|
||||
import { findTerminalBindingConflict } from '../../../shared/workspace-layout/terminal-owner-invariants'
|
||||
import { bumpPtyBindingGeneration } from './pty-binding-generations'
|
||||
|
||||
type PtyBindingPersistenceOperationsRuntime = Pick<
|
||||
StoreRuntimeState,
|
||||
@@ -53,6 +57,7 @@ type PtyBindingPersistenceOperationsRuntime = Pick<
|
||||
| 'state'
|
||||
| 'writeGeneration'
|
||||
| 'writeTimer'
|
||||
| 'ptyBindingGenerations'
|
||||
>
|
||||
|
||||
export type PersistPtyBindingArgs = {
|
||||
@@ -77,6 +82,8 @@ export type PersistPtyBindingArgs = {
|
||||
origin?: PtyBindingOrigin
|
||||
/** Where a new leaf goes. Report-only for now: the span records whether it names today's tab. */
|
||||
placement?: TerminalPanePlacement
|
||||
/** Replaces a gone process: with no `incarnationId`, the pane's recorded one is cleared. */
|
||||
dropsIncarnation?: true
|
||||
}
|
||||
|
||||
const ptyBindingPersistenceOperationsContext = Symbol('PtyBindingPersistenceOperations')
|
||||
@@ -132,9 +139,11 @@ export class PtyBindingPersistenceOperations {
|
||||
const retired = clearReplacedPaneBinding(session, { ...binding, parentTabId: binding.tabId })
|
||||
// Host retirement must not run renderer snapshot repair, which would put the old binding back.
|
||||
publish(retired)
|
||||
bumpPtyBindingGeneration(runtime, resolved, binding.leafId)
|
||||
const staged = cloneWorkspaceSessionState(retired)
|
||||
return {
|
||||
value: true,
|
||||
writtenHostIds: [resolved],
|
||||
rollback: () => {
|
||||
publish(
|
||||
rollbackWorkspaceSessionAfterFailedAsyncWrite(
|
||||
@@ -143,6 +152,7 @@ export class PtyBindingPersistenceOperations {
|
||||
sessions.getWorkspaceSession(resolved)
|
||||
)
|
||||
)
|
||||
bumpPtyBindingGeneration(runtime, resolved, binding.leafId)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -180,20 +190,7 @@ export class PtyBindingPersistenceOperations {
|
||||
outcome = 'refused'
|
||||
return { value: false, persist: false }
|
||||
}
|
||||
// Report-only: a malformed session must not fail the binding it is reporting on.
|
||||
try {
|
||||
span.setPlacement(
|
||||
terminalPanePlacementAgreement(
|
||||
args.placement,
|
||||
session,
|
||||
bindingWorktreeId,
|
||||
args.tabId,
|
||||
args.leafId
|
||||
)
|
||||
)
|
||||
} catch {
|
||||
span.setPlacement('check_threw')
|
||||
}
|
||||
reportPtyBindingPlacement(span, args, session, bindingWorktreeId)
|
||||
const verdict = evaluatePtyBindingFastLane(
|
||||
args,
|
||||
session,
|
||||
@@ -205,18 +202,11 @@ export class PtyBindingPersistenceOperations {
|
||||
outcome = 'fast_lane'
|
||||
return { value: true, persist: false }
|
||||
}
|
||||
// Report-only: the binding is written even when it breaks an invariant, or the check throws.
|
||||
// After the fast lane, so a no-op rebind skips the scan.
|
||||
try {
|
||||
const conflict = findTerminalBindingConflict(args, resolvedHostId, partitions)
|
||||
if (conflict) {
|
||||
span.setOwnerConflict(conflict.reason)
|
||||
}
|
||||
} catch {
|
||||
span.setOwnerConflict('check_threw')
|
||||
}
|
||||
reportPtyBindingOwnerConflict(span, args, resolvedHostId, partitions)
|
||||
return {
|
||||
value: true,
|
||||
writtenHostIds: [resolvedHostId],
|
||||
rollback: writePtyBinding(this, args, session, resolvedHostId, bindingWorktreeId, paneKey)
|
||||
}
|
||||
})
|
||||
@@ -295,6 +285,7 @@ function writePtyBinding(
|
||||
}
|
||||
}
|
||||
applyPtyBinding(args, session, bindingWorktreeId, paneKey)
|
||||
bumpPtyBindingGeneration(runtime, resolvedHostId, args.leafId)
|
||||
runtime.dirtyProfileStateDomains?.add(
|
||||
resolvedHostId === LOCAL_EXECUTION_HOST_ID ? 'workspaceSession' : 'workspaceSessionsByHostId'
|
||||
)
|
||||
@@ -327,6 +318,7 @@ function writePtyBinding(
|
||||
)
|
||||
if (rolledBack !== current) {
|
||||
restore(rolledBack)
|
||||
bumpPtyBindingGeneration(runtime, resolvedHostId, args.leafId)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -38,17 +38,18 @@ export function applyPtyBinding(
|
||||
[repoId]: currentRevision + 1
|
||||
}
|
||||
}
|
||||
if (args.incarnationId) {
|
||||
session.terminalPtyIncarnationsByPaneKey = {
|
||||
...session.terminalPtyIncarnationsByPaneKey,
|
||||
[paneKey]: args.incarnationId
|
||||
}
|
||||
if (session.terminalSurfaceTombstonesByPaneKey?.[paneKey]) {
|
||||
session.terminalSurfaceTombstonesByPaneKey = {
|
||||
...session.terminalSurfaceTombstonesByPaneKey
|
||||
}
|
||||
delete session.terminalSurfaceTombstonesByPaneKey[paneKey]
|
||||
const { [paneKey]: recordedIncarnation, ...otherIncarnations } =
|
||||
session.terminalPtyIncarnationsByPaneKey ?? {}
|
||||
if (args.incarnationId || (args.dropsIncarnation && recordedIncarnation)) {
|
||||
session.terminalPtyIncarnationsByPaneKey = args.incarnationId
|
||||
? { ...session.terminalPtyIncarnationsByPaneKey, [paneKey]: args.incarnationId }
|
||||
: otherIncarnations
|
||||
}
|
||||
if (args.incarnationId && session.terminalSurfaceTombstonesByPaneKey?.[paneKey]) {
|
||||
session.terminalSurfaceTombstonesByPaneKey = {
|
||||
...session.terminalSurfaceTombstonesByPaneKey
|
||||
}
|
||||
delete session.terminalSurfaceTombstonesByPaneKey[paneKey]
|
||||
}
|
||||
const tabs = session.tabsByWorktree?.[bindingWorktreeId]
|
||||
const tab = tabs?.find((t) => t.id === args.tabId)
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { startSpan } from '../../observability/tracer'
|
||||
import type { PtyBindingFastLaneMiss } from './pty-binding-fast-lane'
|
||||
import type { TerminalPanePlacementAgreement } from '../terminal-topology/terminal-pane-placement-agreement'
|
||||
import type { TerminalOwnerConflictReason } from '../../../shared/workspace-layout/terminal-owner-invariants'
|
||||
import {
|
||||
terminalPanePlacementAgreement,
|
||||
type TerminalPanePlacementAgreement
|
||||
} from '../terminal-topology/terminal-pane-placement-agreement'
|
||||
import {
|
||||
findTerminalBindingConflict,
|
||||
type TerminalOwnerConflictReason
|
||||
} from '../../../shared/workspace-layout/terminal-owner-invariants'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { TerminalPanePlacement } from '../../../shared/terminal-pane-placement'
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
|
||||
export type PtyBindingSpanOutcome = 'fast_lane' | 'flushed' | 'refused' | 'threw'
|
||||
|
||||
@@ -103,3 +112,36 @@ export function startPtyBindingSpan(entry: {
|
||||
export function _resetPtyBindingSpanSamplingForTests(): void {
|
||||
fastLaneWindow = null
|
||||
}
|
||||
|
||||
/** Report-only: a malformed session must not fail the binding it is reporting on. */
|
||||
export function reportPtyBindingPlacement(
|
||||
span: PtyBindingSpan,
|
||||
args: { placement?: TerminalPanePlacement; tabId: string; leafId: string },
|
||||
session: WorkspaceSessionState,
|
||||
worktreeId: string
|
||||
): void {
|
||||
try {
|
||||
span.setPlacement(
|
||||
terminalPanePlacementAgreement(args.placement, session, worktreeId, args.tabId, args.leafId)
|
||||
)
|
||||
} catch {
|
||||
span.setPlacement('check_threw')
|
||||
}
|
||||
}
|
||||
|
||||
/** Report-only: the binding is written even when it breaks an invariant, or the check throws. */
|
||||
export function reportPtyBindingOwnerConflict(
|
||||
span: PtyBindingSpan,
|
||||
binding: Parameters<typeof findTerminalBindingConflict>[0],
|
||||
hostId: ExecutionHostId,
|
||||
partitions: Parameters<typeof findTerminalBindingConflict>[2]
|
||||
): void {
|
||||
try {
|
||||
const conflict = findTerminalBindingConflict(binding, hostId, partitions)
|
||||
if (conflict) {
|
||||
span.setOwnerConflict(conflict.reason)
|
||||
}
|
||||
} catch {
|
||||
span.setOwnerConflict('check_threw')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { removeStaleDurableWriteTempFiles } from '../../durable-file-write'
|
||||
import type { GlobalSettings } from '../../../shared/global-settings-types'
|
||||
import type { PersistedState } from '../../../shared/persisted-state-types'
|
||||
import type { WorkspaceSessionWriteListener } from './workspace-session-write-listeners'
|
||||
import type {
|
||||
WorkspaceSessionOperationListener,
|
||||
WorkspaceSessionWriteListener
|
||||
} from './workspace-session-write-listeners'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { StateWriteListener } from './write-scheduling'
|
||||
import type { ActiveViewPreference } from '../../active-view-preference'
|
||||
import {
|
||||
@@ -32,6 +36,8 @@ export type DurableProfileStateMutation<T> = {
|
||||
/** 'if-dirty' fences an existing change without rewriting an already durable generation. */
|
||||
persist?: boolean | 'if-dirty'
|
||||
rollback?: () => void
|
||||
/** The only session partitions the mutation wrote, so readers re-read just those; absent: any. */
|
||||
writtenHostIds?: readonly ExecutionHostId[]
|
||||
}
|
||||
|
||||
export type StoreRuntimeOptions = {
|
||||
@@ -104,6 +110,10 @@ export class StoreRuntimeState {
|
||||
uiChangeListeners = new Set<(ui: PersistedState['ui']) => void>()
|
||||
/** Observers of persisted workspace-session writes; see notifyWorkspaceSessionWritten. */
|
||||
readonly workspaceSessionWriteListeners = new Set<WorkspaceSessionWriteListener>()
|
||||
/** Observers of each durable mutation's end; see notifyWorkspaceSessionOperationEnded. */
|
||||
readonly workspaceSessionOperationListeners = new Set<WorkspaceSessionOperationListener>()
|
||||
/** `${hostId}\0${leafId}` → its pane's binding generation, in memory only (layout-owner builds). */
|
||||
readonly ptyBindingGenerations = new Map<string, number>()
|
||||
/** Observers of every write; see WriteSchedulingOperations.onStateWritten. */
|
||||
readonly stateWriteListeners = new Set<StateWriteListener>()
|
||||
projectHostOperations: ProjectHostPersistenceOperations | null = null
|
||||
|
||||
@@ -3,8 +3,8 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
import { TEST_LEAF_1, TEST_LEAF_2 } from '../../persistence-session-fixtures'
|
||||
import type { WorkspaceLayoutEvent } from '../../../shared/workspace-layout/workspace-layout-stream-frames'
|
||||
import { WorkspaceLayoutStream } from '../../runtime/workspace-layout-stream'
|
||||
import { collectLayoutEvents } from '../../runtime/workspace-layout-stream-events.test-fixture'
|
||||
import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors'
|
||||
import { fixture } from './profile-state-delayed-authority-fixture'
|
||||
import type { Store } from './store'
|
||||
@@ -66,20 +66,22 @@ export function registerWorkspaceSessionWriteListenersTests(): void {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const { store, authority, readState } = await fixture()
|
||||
const pushes: WorkspaceLayoutEvent[] = []
|
||||
const collector = collectLayoutEvents()
|
||||
const pushes = collector.events
|
||||
if (mode !== 'none') {
|
||||
const stream = new WorkspaceLayoutStream({
|
||||
store: () => store,
|
||||
homeHostId: (key) => homeOf(store, key),
|
||||
liveTitle: () => null
|
||||
})
|
||||
stream.subscribe(
|
||||
const { snapshot } = stream.subscribe(
|
||||
mode === 'publisher'
|
||||
? (event) => pushes.push(event)
|
||||
? collector.listener
|
||||
: () => {
|
||||
throw new Error('listener failed')
|
||||
}
|
||||
)
|
||||
collector.seed(snapshot)
|
||||
}
|
||||
const snapshots: unknown[] = []
|
||||
const step = async (run: () => unknown) => {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
|
||||
import type { StoreRuntimeState } from './store-runtime-state'
|
||||
|
||||
/** Gets the one partition a write changed, or null when any partition may have changed. */
|
||||
export type WorkspaceSessionWriteListener = (hostId: ExecutionHostId | null) => void
|
||||
|
||||
/** Gets the partitions one durable mutation may have written, or null when any may have changed. */
|
||||
export type WorkspaceSessionOperationListener = (hostIds: readonly ExecutionHostId[] | null) => void
|
||||
|
||||
const WORKSPACE_SESSION_DOMAINS: readonly string[] = [
|
||||
'workspaceSession',
|
||||
'workspaceSessionsByHostId'
|
||||
@@ -25,3 +29,34 @@ export function notifyWorkspaceSessionWritten(
|
||||
listener(hostId ?? null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A durable mutation's end. Switch point: a layout-owner build names the partitions the mutation
|
||||
* declared, then tells operation listeners its writes are all in; others notify as any write.
|
||||
*/
|
||||
export function notifyWorkspaceSessionOperationEnded(
|
||||
runtime: Pick<
|
||||
StoreRuntimeState,
|
||||
'workspaceSessionWriteListeners' | 'workspaceSessionOperationListeners'
|
||||
>,
|
||||
hostIds: readonly ExecutionHostId[] | null
|
||||
): void {
|
||||
if (!ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
notifyWorkspaceSessionWritten(runtime)
|
||||
return
|
||||
}
|
||||
if (hostIds === null) {
|
||||
notifyWorkspaceSessionWritten(runtime)
|
||||
}
|
||||
for (const hostId of hostIds ?? []) {
|
||||
notifyWorkspaceSessionWritten(runtime, undefined, hostId)
|
||||
}
|
||||
for (const listener of runtime.workspaceSessionOperationListeners) {
|
||||
// The operation already committed: a reader's throw must not fail it or the next reader.
|
||||
try {
|
||||
listener(hostIds)
|
||||
} catch (error) {
|
||||
console.warn('[persistence] workspace-session operation listener failed:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import { enqueueWrite } from './primary-state-writes'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import {
|
||||
notifyWorkspaceSessionWritten,
|
||||
type WorkspaceSessionOperationListener,
|
||||
type WorkspaceSessionWriteListener
|
||||
} from './workspace-session-write-listeners'
|
||||
import { ptyBindingGenerationOf } from './pty-binding-generations'
|
||||
|
||||
/** Gets the domains a write changed; undefined when it may have changed any of them. */
|
||||
export type StateWriteListener = (dirtyDomains: readonly string[] | undefined) => void
|
||||
@@ -26,7 +28,9 @@ type WriteSchedulingOperationsRuntime = Pick<
|
||||
| 'writeTimer'
|
||||
| 'writesFrozen'
|
||||
| 'workspaceSessionWriteListeners'
|
||||
| 'workspaceSessionOperationListeners'
|
||||
| 'stateWriteListeners'
|
||||
| 'ptyBindingGenerations'
|
||||
>
|
||||
|
||||
const writeSchedulingOperationsContext = Symbol('WriteSchedulingOperations')
|
||||
@@ -56,6 +60,23 @@ export class WriteSchedulingOperations {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout-owner builds: called once per durable mutation, after its partitions' write notices,
|
||||
* with those partitions; listeners must not throw or block.
|
||||
*/
|
||||
onWorkspaceSessionOperationEnded(listener: WorkspaceSessionOperationListener): () => void {
|
||||
const { runtime } = this[writeSchedulingOperationsContext]
|
||||
runtime.workspaceSessionOperationListeners.add(listener)
|
||||
return () => {
|
||||
runtime.workspaceSessionOperationListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
/** A pane's binding generation in this process, for readers of its writes; see pty-binding-generations. */
|
||||
getPtyBindingGeneration(hostId: ExecutionHostId, leafId: string): number | undefined {
|
||||
return ptyBindingGenerationOf(this[writeSchedulingOperationsContext].runtime, hostId, leafId)
|
||||
}
|
||||
|
||||
/** Called after each in-memory write that schedules a save; listeners must not throw or block. */
|
||||
onStateWritten(listener: StateWriteListener): () => void {
|
||||
const { runtime } = this[writeSchedulingOperationsContext]
|
||||
|
||||
@@ -8,7 +8,11 @@ import {
|
||||
resolveHostId,
|
||||
type SessionHostPartitionOperations
|
||||
} from '../loading-store/session-host-partitions'
|
||||
import type { StoreRuntimeState } from '../loading-store/store-runtime-state'
|
||||
import type {
|
||||
DurableProfileStateMutation,
|
||||
StoreRuntimeState
|
||||
} from '../loading-store/store-runtime-state'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import { applyLayout, keepFrozen, topologyCommitContext } from './terminal-topology-commit'
|
||||
import { applyLayoutRenames } from './terminal-layout-renames-commit'
|
||||
import { moveTabAcrossPartitions } from './cross-partition-tab-move'
|
||||
@@ -19,6 +23,14 @@ import {
|
||||
type DuplicatePtyPaneTransfer
|
||||
} from './duplicate-pty-pane-repair'
|
||||
|
||||
/** A commit that writes only these partitions says so, so readers re-read just those. */
|
||||
function writingOnly<T>(
|
||||
hostIds: readonly ExecutionHostId[],
|
||||
mutate: () => DurableProfileStateMutation<T>
|
||||
): () => DurableProfileStateMutation<T> {
|
||||
return () => ({ ...mutate(), writtenHostIds: hostIds })
|
||||
}
|
||||
|
||||
type LayoutCommandRuntime = Pick<
|
||||
StoreRuntimeState,
|
||||
'runDurableMutation' | 'state' | 'dirtyProfileStateDomains' | 'writesFrozen'
|
||||
@@ -46,7 +58,7 @@ export class LayoutCommandPersistence {
|
||||
const context = this[layoutCommandPersistenceContext]
|
||||
const resolved = hostIds.length > 0 ? hostIds.map(resolveHostId) : [resolveHostId()]
|
||||
return context.runtime.runDurableMutation(
|
||||
applyLayout(resolved, command, topologyCommitContext(context))
|
||||
writingOnly(resolved, applyLayout(resolved, command, topologyCommitContext(context)))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -55,10 +67,14 @@ export class LayoutCommandPersistence {
|
||||
batches: readonly { hostId: string; renames: readonly CommandOf<'renameTab'>[] }[]
|
||||
): Promise<{ rename: CommandOf<'renameTab'>; code: LayoutRefusalCode }[]> {
|
||||
const context = this[layoutCommandPersistenceContext]
|
||||
const resolved = batches.map(({ hostId, renames }) => ({
|
||||
hostId: resolveHostId(hostId),
|
||||
renames
|
||||
}))
|
||||
return context.runtime.runDurableMutation(
|
||||
applyLayoutRenames(
|
||||
batches.map(({ hostId, renames }) => ({ hostId: resolveHostId(hostId), renames })),
|
||||
topologyCommitContext(context)
|
||||
writingOnly(
|
||||
resolved.map(({ hostId }) => hostId),
|
||||
applyLayoutRenames(resolved, topologyCommitContext(context))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
|
||||
import { getDefaultWorkspaceSession } from '../../../shared/constants'
|
||||
import type { CommandOf } from '../../../shared/workspace-layout/workspace-layout-command-types'
|
||||
import { WorkspaceLayoutStream } from '../../runtime/workspace-layout-stream'
|
||||
@@ -84,8 +85,8 @@ describe('Store.applyTabRenames (layout.apply renameTabs)', () => {
|
||||
expect(refused).toEqual([
|
||||
{ rename: rename(FIXTURE_GIT_WORKTREE_ID, 'missing', 'Gone'), code: 'tab_not_found' }
|
||||
])
|
||||
// Both partitions in one durable mutation: one write notice.
|
||||
expect(written).toHaveLength(1)
|
||||
// Both partitions in one durable mutation: one notice for any partition, or one per partition.
|
||||
expect(written).toEqual(ORCA_LAYOUT_OWNER_IS_MAIN ? ['local', SSH] : [null])
|
||||
const reopened = await reopenTopologyStore(store, directory)
|
||||
expect(generatedTitles(reopened)).toEqual([
|
||||
['tab-0', 'Fix the login page'],
|
||||
@@ -103,7 +104,13 @@ describe('Store.applyTabRenames (layout.apply renameTabs)', () => {
|
||||
liveTitle: () => null
|
||||
})
|
||||
const events: string[] = []
|
||||
stream.subscribe((event) => events.push(event.type === 'workspace' ? event.key : event.type))
|
||||
stream.subscribe((event) =>
|
||||
events.push(
|
||||
...(event.type === 'records'
|
||||
? event.workspaces.map((change) => change.key)
|
||||
: [event.type === 'workspace' ? event.key : event.type])
|
||||
)
|
||||
)
|
||||
|
||||
await store.applyTabRenames([
|
||||
{
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '../../shared/workspace-layout/workspace-layout-session.test-fixture'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { WorkspaceLayoutStream } from './workspace-layout-stream'
|
||||
import { collectLayoutEvents } from './workspace-layout-stream-events.test-fixture'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ layoutOwnerIsMain: true }))
|
||||
vi.mock('../../shared/layout-owner-switch', () => ({
|
||||
@@ -134,8 +135,10 @@ describe('a workspace the router throws on', () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const { stream, route, write } = streamThrowingOn(FOLDER_KEY)
|
||||
route.throws = false
|
||||
const events: string[] = []
|
||||
const first = stream.subscribe((event) => events.push(`${event.type} ${event.key}`))
|
||||
const collector = collectLayoutEvents()
|
||||
const first = stream.subscribe(collector.listener)
|
||||
collector.seed(first.snapshot)
|
||||
const events = collector.events
|
||||
expect(first.snapshot.map((entry) => entry.key)).toEqual([GIT_KEY, FOLDER_KEY])
|
||||
route.throws = true
|
||||
write()
|
||||
|
||||
@@ -21,7 +21,7 @@ import { registerWorktreeChangeInvalidator } from '../ipc/worktree-change-invali
|
||||
import type { AgentSessionRecordStore } from './agent-session-record-store'
|
||||
import { peekOpenedAgentSessionRecordStore } from './agent-session-record-store-slot'
|
||||
import { createAgentLaunchRecordWarmupGate } from './agent-launch-record-warmup-gate'
|
||||
import { WorkspaceLayoutStream } from './workspace-layout-stream'
|
||||
import { WorkspaceLayoutStream, type WorkspaceLayoutReads } from './workspace-layout-stream'
|
||||
import type { RemoteLayoutSnapshot } from '../../shared/remote-layout-snapshot'
|
||||
import { registerDetectedWorktreeScanInvalidation } from '../ipc/worktrees/listing/register-detected-worktree-scan-invalidation'
|
||||
import {
|
||||
@@ -263,6 +263,11 @@ class OrcaRuntimeService extends OrcaRuntimeSidebarBase {
|
||||
return this.workspaceLayoutStream.subscribe(listener)
|
||||
}
|
||||
|
||||
/** Main's one layout projection for readers beside the stream, with or without a window. */
|
||||
get workspaceLayoutReads(): WorkspaceLayoutReads {
|
||||
return this.workspaceLayoutStream
|
||||
}
|
||||
|
||||
/** A window's layout command (`layout.apply`), applied to the partitions that hold its tab. */
|
||||
async applyLayoutCommand(command: WindowLayoutCommand): Promise<LayoutApplyReply> {
|
||||
const apply = (): Promise<LayoutApplyReply> =>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Each pane's live terminal title laid over a published layout: live state, never saved.
|
||||
|
||||
import type { PublishedWorkspaceLayout } from '../../shared/workspace-layout/workspace-layout-published'
|
||||
|
||||
/** `titles` in tab and pane order, null for a pane with none; the same layout when all are null. */
|
||||
export function withLiveTitles(
|
||||
layout: PublishedWorkspaceLayout,
|
||||
titles: (string | null)[]
|
||||
): PublishedWorkspaceLayout {
|
||||
if (titles.every((title) => title === null)) {
|
||||
return layout
|
||||
}
|
||||
let index = 0
|
||||
return {
|
||||
...layout,
|
||||
tabs: layout.tabs.map((tab) =>
|
||||
tab.terminal
|
||||
? {
|
||||
...tab,
|
||||
terminal: {
|
||||
...tab.terminal,
|
||||
panes: tab.terminal.panes.map((pane) => {
|
||||
const liveTitle = titles[index++]
|
||||
return liveTitle === null ? pane : { ...pane, liveTitle }
|
||||
})
|
||||
}
|
||||
}
|
||||
: tab
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import type { WorkspaceLayoutEvent } from '../../../../shared/workspace-layout/workspace-layout-stream-frames'
|
||||
import type { WorkspaceLayoutStreamEvent } from '../../../../shared/workspace-layout/workspace-layout-stream-frames'
|
||||
import { eraseRpcMethods, isStreamingMethod, type RpcContext } from '../core'
|
||||
import { ALL_RPC_METHODS } from './index'
|
||||
import { RUNTIME_CAPABILITIES } from '../../../../shared/protocol-version'
|
||||
@@ -11,12 +11,12 @@ const subscribe = methods.find((method) => method.name === 'layout.subscribe')
|
||||
const unsubscribeMethod = methods.find((method) => method.name === 'layout.unsubscribe')
|
||||
const getRemoteSnapshots = methods.find((method) => method.name === 'layout.getRemoteSnapshots')
|
||||
|
||||
function harness() {
|
||||
function harness(epoch?: string) {
|
||||
const cleanups = new Map<string, () => void>()
|
||||
let publish: (event: WorkspaceLayoutEvent) => void = () => {}
|
||||
let publish: (event: WorkspaceLayoutStreamEvent) => void = () => {}
|
||||
const unsubscribe = vi.fn()
|
||||
const fake = {
|
||||
subscribeWorkspaceLayouts: (listener: (event: WorkspaceLayoutEvent) => void) => {
|
||||
subscribeWorkspaceLayouts: (listener: (event: WorkspaceLayoutStreamEvent) => void) => {
|
||||
publish = listener
|
||||
return {
|
||||
snapshot: [
|
||||
@@ -24,6 +24,7 @@ function harness() {
|
||||
{ key: 'b', layout: { worktreeId: 'b' } }
|
||||
],
|
||||
seq: 4,
|
||||
...(epoch ? { epoch } : {}),
|
||||
unsubscribe
|
||||
}
|
||||
},
|
||||
@@ -36,7 +37,7 @@ function harness() {
|
||||
runtime,
|
||||
cleanups,
|
||||
unsubscribe,
|
||||
publish: (event: WorkspaceLayoutEvent) => publish(event)
|
||||
publish: (event: WorkspaceLayoutStreamEvent) => publish(event)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +77,32 @@ describe('layout.subscribe', () => {
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('sends each records frame with only the named workspaces, an empty one too', async () => {
|
||||
const { runtime, cleanups, publish } = harness('epoch-1')
|
||||
const emitted: unknown[] = []
|
||||
if (!subscribe || !isStreamingMethod(subscribe)) {
|
||||
throw new Error('layout.subscribe is not a streaming method')
|
||||
}
|
||||
const done = subscribe.handler(
|
||||
{ workspaces: ['b'] },
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only runtime and connectionId from its context.
|
||||
{ runtime, connectionId: 'conn-1' } as RpcContext,
|
||||
(event) => emitted.push(event)
|
||||
)
|
||||
const removed = (key: string) => ({ key, change: 'removed' as const })
|
||||
publish({ type: 'records', epoch: 'epoch-1', seq: 5, workspaces: [removed('a'), removed('b')] })
|
||||
// Sent though it names none of them, so the subscriber's positions stay gapless.
|
||||
publish({ type: 'records', epoch: 'epoch-1', seq: 6, workspaces: [removed('a')] })
|
||||
runtime.cleanupSubscription([...cleanups.keys()][0]!)
|
||||
await done
|
||||
expect(emitted).toEqual([
|
||||
expect.objectContaining({ type: 'snapshot', seq: 4, epoch: 'epoch-1' }),
|
||||
{ type: 'records', epoch: 'epoch-1', seq: 5, workspaces: [removed('b')] },
|
||||
{ type: 'records', epoch: 'epoch-1', seq: 6, workspaces: [] },
|
||||
{ type: 'end' }
|
||||
])
|
||||
})
|
||||
|
||||
it('ends a subscription only for the connection that opened it', async () => {
|
||||
const { runtime, unsubscribe } = harness()
|
||||
const emitted: unknown[] = []
|
||||
|
||||
@@ -30,8 +30,11 @@ export const WORKSPACE_LAYOUT_METHODS = [
|
||||
const subscriptionId = `layout-${connectionId ?? 'inproc'}-${++layoutSubscriptionSeq}`
|
||||
const cleanupKey = layoutCleanupKey(connectionId, subscriptionId)
|
||||
const release = (): void => runtime.cleanupSubscription(cleanupKey)
|
||||
const { snapshot, seq, unsubscribe } = runtime.subscribeWorkspaceLayouts((event) => {
|
||||
if (wanted(event.key)) {
|
||||
const { snapshot, seq, epoch, unsubscribe } = runtime.subscribeWorkspaceLayouts((event) => {
|
||||
if (event.type === 'records') {
|
||||
// Sent even when it names none of them, so the subscriber's positions stay gapless.
|
||||
emit({ ...event, workspaces: event.workspaces.filter((change) => wanted(change.key)) })
|
||||
} else if (wanted(event.key)) {
|
||||
emit(event)
|
||||
}
|
||||
})
|
||||
@@ -50,7 +53,8 @@ export const WORKSPACE_LAYOUT_METHODS = [
|
||||
type: 'snapshot',
|
||||
subscriptionId,
|
||||
workspaces: snapshot.filter((entry) => wanted(entry.key)),
|
||||
seq
|
||||
seq,
|
||||
...(epoch ? { epoch } : {})
|
||||
})
|
||||
if (signal?.aborted) {
|
||||
release()
|
||||
|
||||
@@ -50,6 +50,7 @@ export type RuntimeStore = {
|
||||
getWorkspaceSession?: Store['getWorkspaceSession']
|
||||
getWorkspaceSessionHostIds?: Store['getWorkspaceSessionHostIds']
|
||||
onWorkspaceSessionWritten?: Store['onWorkspaceSessionWritten']
|
||||
onWorkspaceSessionOperationEnded?: Store['onWorkspaceSessionOperationEnded']
|
||||
onStateWritten?: Store['onStateWritten']
|
||||
setWorkspaceSession?: Store['setWorkspaceSession']
|
||||
getRemoteLayoutSnapshots?: Store['getRemoteLayoutSnapshots']
|
||||
@@ -70,6 +71,7 @@ export type RuntimeStore = {
|
||||
peekOrcadMigrationTerminalRestoreHint?: Store['peekOrcadMigrationTerminalRestoreHint']
|
||||
consumeOrcadMigrationTerminalRestoreHint?: Store['consumeOrcadMigrationTerminalRestoreHint']
|
||||
persistPtyBinding?: Store['persistPtyBinding']
|
||||
getPtyBindingGeneration?: Store['getPtyBindingGeneration']
|
||||
admitTerminalPane?: Store['admitTerminalPane']
|
||||
withdrawTerminalPane?: Store['withdrawTerminalPane']
|
||||
applyLayoutCommand?: Store['applyLayoutCommand']
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// Installs main's model check on every layout records frame for one test's life, and freezes each
|
||||
// partition model handed to a reader so a reader that mutates the shared model throws.
|
||||
|
||||
import { layoutFrameModelErrors } from './workspace-layout-frame-model-check'
|
||||
import {
|
||||
observeWorkspaceLayoutFrames,
|
||||
observeWorkspaceLayoutPartitionReads
|
||||
} from './workspace-layout-frame-observer'
|
||||
|
||||
export function installLayoutFrameModelCheck(): {
|
||||
/** Every mismatch found, frame by frame. */
|
||||
failures: string[]
|
||||
/** How many frames were checked. */
|
||||
checked: () => number
|
||||
uninstall: () => void
|
||||
} {
|
||||
const failures: string[] = []
|
||||
let checked = 0
|
||||
observeWorkspaceLayoutFrames((observation) => {
|
||||
checked += 1
|
||||
failures.push(...layoutFrameModelErrors(observation))
|
||||
})
|
||||
observeWorkspaceLayoutPartitionReads(deepFreeze)
|
||||
return {
|
||||
failures,
|
||||
checked: () => checked,
|
||||
uninstall: () => {
|
||||
observeWorkspaceLayoutFrames(null)
|
||||
observeWorkspaceLayoutPartitionReads(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function deepFreeze(value: unknown): void {
|
||||
if (typeof value !== 'object' || value === null || Object.isFrozen(value)) {
|
||||
return
|
||||
}
|
||||
Object.freeze(value)
|
||||
for (const inner of Object.values(value)) {
|
||||
deepFreeze(inner)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Main's model check on a records frame: the frame applied to what subscribers held must equal a
|
||||
// fresh projection of every partition. Tests that install it check every frame; dev builds log a
|
||||
// mismatch on frames a Store write caused (see workspace-layout-frame-observer.ts).
|
||||
|
||||
import { stableJson } from '../../shared/workspace-layout/workspace-layout-load-report'
|
||||
import { applyWorkspaceLayoutRecordChange } from '../../shared/workspace-layout/workspace-layout-records'
|
||||
import type { LayoutFrameObservation } from './workspace-layout-frame-observer'
|
||||
|
||||
/** Empty when the frame holds exactly what changed. */
|
||||
export function layoutFrameModelErrors({ frame, before, fresh }: LayoutFrameObservation): string[] {
|
||||
const errors: string[] = []
|
||||
const applied = new Map(before)
|
||||
for (const change of frame.workspaces) {
|
||||
try {
|
||||
const next = applyWorkspaceLayoutRecordChange(applied.get(change.key), change)
|
||||
if (next) {
|
||||
applied.set(change.key, next)
|
||||
} else {
|
||||
applied.delete(change.key)
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(`frame ${frame.seq}: ${change.key} does not apply: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
const expected = fresh()
|
||||
for (const key of new Set([...applied.keys(), ...expected.keys()])) {
|
||||
const shown = applied.get(key)
|
||||
const published = expected.get(key)
|
||||
if (!shown || !published) {
|
||||
errors.push(
|
||||
`frame ${frame.seq}: ${key} is ${shown ? 'shown but not published' : 'published but not shown'}`
|
||||
)
|
||||
} else if (stableJson(shown) !== stableJson(published)) {
|
||||
errors.push(`frame ${frame.seq}: ${key} differs from a fresh projection`)
|
||||
}
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
let mismatchLogged = false
|
||||
|
||||
/** The dev build's check: logs the first mismatching frame only; a title-only frame is skipped. */
|
||||
export function logLayoutFrameModelErrors(observation: LayoutFrameObservation): void {
|
||||
if (mismatchLogged || observation.titlesOnly) {
|
||||
return
|
||||
}
|
||||
const errors = layoutFrameModelErrors(observation)
|
||||
if (errors.length > 0) {
|
||||
mismatchLogged = true
|
||||
console.warn('[workspace-layout] a layout frame does not match main’s model:', errors)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Test hooks on main's layout stream. `installLayoutFrameModelCheck` (installed by the operation-frame
|
||||
// tests) checks every records frame against a fresh projection and freezes each partition read.
|
||||
// Dev builds log the frame check on structural frames; release builds run neither.
|
||||
|
||||
import { logLayoutFrameModelErrors } from './workspace-layout-frame-model-check'
|
||||
import type { LoadedWorkspaceLayout } from '../../shared/workspace-layout/workspace-layout-load-types'
|
||||
import type { PublishedWorkspaceLayout } from '../../shared/workspace-layout/workspace-layout-published'
|
||||
import type { WorkspaceLayoutRecordsFrame } from '../../shared/workspace-layout/workspace-layout-stream-frames'
|
||||
|
||||
export type LayoutFrameObservation = {
|
||||
frame: WorkspaceLayoutRecordsFrame
|
||||
/** What subscribers held before the frame. */
|
||||
before: ReadonlyMap<string, PublishedWorkspaceLayout>
|
||||
/** Every workspace as a projection from scratch publishes it now. */
|
||||
fresh: () => ReadonlyMap<string, PublishedWorkspaceLayout>
|
||||
/** Only live titles changed: no Store write, so no projection to check. */
|
||||
titlesOnly: boolean
|
||||
}
|
||||
|
||||
export type LayoutFrameObserver = (observation: LayoutFrameObservation) => void
|
||||
|
||||
let observer: LayoutFrameObserver | null = null
|
||||
|
||||
// Why NODE_ENV: electron-vite sets it to 'development' only for a dev build; tests set 'test'.
|
||||
const DEV_BUILD_CHECK: LayoutFrameObserver | null =
|
||||
process.env.NODE_ENV === 'development' ? logLayoutFrameModelErrors : null
|
||||
|
||||
/** Null removes the observer. */
|
||||
export function observeWorkspaceLayoutFrames(next: LayoutFrameObserver | null): void {
|
||||
observer = next
|
||||
}
|
||||
|
||||
export function workspaceLayoutFrameObserver(): LayoutFrameObserver | null {
|
||||
return observer ?? DEV_BUILD_CHECK
|
||||
}
|
||||
|
||||
let partitionReadObserver: ((loaded: LoadedWorkspaceLayout) => void) | null = null
|
||||
|
||||
/** Tests: sees each partition model handed to a reader, so a mutating reader fails. Null removes it. */
|
||||
export function observeWorkspaceLayoutPartitionReads(
|
||||
next: ((loaded: LoadedWorkspaceLayout) => void) | null
|
||||
): void {
|
||||
partitionReadObserver = next
|
||||
}
|
||||
|
||||
export function workspaceLayoutPartitionReadObserver():
|
||||
| ((loaded: LoadedWorkspaceLayout) => void)
|
||||
| null {
|
||||
return partitionReadObserver
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { PublishedPane } from '../../shared/workspace-layout/workspace-layo
|
||||
import type { WorkspaceLayoutEvent } from '../../shared/workspace-layout/workspace-layout-stream-frames'
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { collectLayoutEvents } from './workspace-layout-stream-events.test-fixture'
|
||||
|
||||
const WORKTREE_ID = 'repo::/worktree'
|
||||
const REPO = { id: 'repo', path: '/worktree', displayName: 'repo', badgeColor: 'blue', addedAt: 1 }
|
||||
@@ -68,8 +69,10 @@ function paneOf(event: WorkspaceLayoutEvent): PublishedPane | undefined {
|
||||
describe('live pane titles on the layout stream', () => {
|
||||
it('publishes the title a pane terminal sets, once per change', async () => {
|
||||
const runtime = runtimeWithPane()
|
||||
const events: WorkspaceLayoutEvent[] = []
|
||||
const { snapshot } = runtime.subscribeWorkspaceLayouts((event) => events.push(event))
|
||||
const collector = collectLayoutEvents()
|
||||
const { events } = collector
|
||||
const { snapshot } = runtime.subscribeWorkspaceLayouts(collector.listener)
|
||||
collector.seed(snapshot)
|
||||
expect(snapshot.map((entry) => entry.key)).toEqual([WORKTREE_ID])
|
||||
expect(snapshot[0].layout.tabs[0]?.terminal?.panes[0]).toEqual({ leafId: LEAF, ptyId: PTY_ID })
|
||||
|
||||
@@ -86,8 +89,9 @@ describe('live pane titles on the layout stream', () => {
|
||||
|
||||
it('sends no frame per spinner glyph while an agent works', async () => {
|
||||
const runtime = runtimeWithPane()
|
||||
const events: WorkspaceLayoutEvent[] = []
|
||||
runtime.subscribeWorkspaceLayouts((event) => events.push(event))
|
||||
const collector = collectLayoutEvents()
|
||||
const { events } = collector
|
||||
collector.seed(runtime.subscribeWorkspaceLayouts(collector.listener).snapshot)
|
||||
const spinner = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
||||
for (let frame = 0; frame < 100; frame++) {
|
||||
runtime.onPtyData(
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
// Main publishes one records frame per operation on a real Store (design E1), checked against a
|
||||
// fresh projection on every frame, with or without a window.
|
||||
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultWorkspaceSession } from '../../shared/constants'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { GeneratedTabTitle } from '../../shared/workspace-layout/workspace-layout-command-types'
|
||||
import type { WorkspaceLayoutStreamEvent } from '../../shared/workspace-layout/workspace-layout-stream-frames'
|
||||
import type { Store } from '../persistence/loading-store/store'
|
||||
import { WindowSession } from '../persistence/terminal-topology/terminal-topology-window-session-fixture'
|
||||
import {
|
||||
emptyTerminalSessionProfile,
|
||||
FIXTURE_FOLDER_WORKTREE_ID as FOLDER,
|
||||
FIXTURE_GIT_WORKTREE_ID as GIT,
|
||||
openTopologyStore
|
||||
} from '../persistence/terminal-topology/terminal-topology-profile-fixture'
|
||||
import { applyWindowLayoutCommand } from './runtime-layout-apply'
|
||||
import {
|
||||
layoutFrameModelErrors,
|
||||
logLayoutFrameModelErrors
|
||||
} from './workspace-layout-frame-model-check'
|
||||
import { installLayoutFrameModelCheck } from './workspace-layout-frame-model-check.test-fixture'
|
||||
import {
|
||||
observeWorkspaceLayoutFrames,
|
||||
type LayoutFrameObservation
|
||||
} from './workspace-layout-frame-observer'
|
||||
import { WorkspaceLayoutStream, type LayoutOperationCommit } from './workspace-layout-stream'
|
||||
|
||||
const layoutOwner = vi.hoisted(() => ({ isMain: true }))
|
||||
vi.mock('../../shared/layout-owner-switch', () => ({
|
||||
get ORCA_LAYOUT_OWNER_IS_MAIN() {
|
||||
return layoutOwner.isMain
|
||||
}
|
||||
}))
|
||||
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
|
||||
vi.mock('../ssh/config/ssh-config-parser', () => ({
|
||||
loadUserSshConfig: () => ({ hosts: [] }),
|
||||
sshConfigHostsToTargets: () => []
|
||||
}))
|
||||
|
||||
const SSH: ExecutionHostId = 'ssh:target-1'
|
||||
const SSH_KEY = 'repo-ssh::/srv/api'
|
||||
const homeOf = (key: string): ExecutionHostId => (key === SSH_KEY ? SSH : 'local')
|
||||
const leafOf = (index: number) => `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`
|
||||
const directories: string[] = []
|
||||
|
||||
/** Tabs `tab-<workspace>-<n>` in each workspace, its panes unbound until a test binds one. */
|
||||
async function storeWith(tabs: Record<string, number>): Promise<Store> {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-operation-frames-'))
|
||||
directories.push(directory)
|
||||
const store = await openTopologyStore(directory, emptyTerminalSessionProfile())
|
||||
const sessions = new Map<ExecutionHostId, WindowSession>()
|
||||
let leaf = 0
|
||||
for (const [workspace, count] of Object.entries(tabs)) {
|
||||
const hostId = homeOf(workspace)
|
||||
const session = sessions.get(hostId) ?? new WindowSession(getDefaultWorkspaceSession())
|
||||
sessions.set(hostId, session)
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
session.addTab(workspace, tabIdOf(workspace, index), leafOf(leaf++))
|
||||
}
|
||||
}
|
||||
for (const [hostId, session] of sessions) {
|
||||
store.setWorkspaceSession(session.snapshot(), hostId)
|
||||
}
|
||||
await store.flushPendingOrThrowAsync()
|
||||
return store
|
||||
}
|
||||
|
||||
const tabIdOf = (workspace: string, index: number) =>
|
||||
`tab-${workspace.replace(/\W/g, '-')}-${index}`
|
||||
|
||||
function streamOver(store: Store): WorkspaceLayoutStream {
|
||||
return new WorkspaceLayoutStream({
|
||||
store: () => store,
|
||||
homeHostId: homeOf,
|
||||
liveTitle: () => null
|
||||
})
|
||||
}
|
||||
|
||||
function titles(tabs: Record<string, number>): GeneratedTabTitle[] {
|
||||
return Object.entries(tabs).flatMap(([workspace, count]) =>
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
workspace,
|
||||
tabId: tabIdOf(workspace, index),
|
||||
title: `Title ${index}`
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
const renameTabs = (store: Store, renames: GeneratedTabTitle[]) =>
|
||||
applyWindowLayoutCommand(store, (workspace) => [homeOf(workspace)], {
|
||||
type: 'renameTabs',
|
||||
kind: 'generated',
|
||||
renames
|
||||
})
|
||||
|
||||
let check: ReturnType<typeof installLayoutFrameModelCheck>
|
||||
|
||||
beforeEach(() => {
|
||||
check = installLayoutFrameModelCheck()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
check.uninstall()
|
||||
for (const directory of directories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('one records frame per main operation', () => {
|
||||
const SPREAD = { [GIT]: 50, [FOLDER]: 49, [SSH_KEY]: 1 }
|
||||
|
||||
it('publishes 100 generated titles in three workspaces and two partitions as one frame', async () => {
|
||||
const store = await storeWith(SPREAD)
|
||||
const frames: WorkspaceLayoutStreamEvent[] = []
|
||||
const { seq } = streamOver(store).subscribe((frame) => frames.push(frame))
|
||||
|
||||
await expect(renameTabs(store, titles(SPREAD))).resolves.toEqual({ ok: true, result: {} })
|
||||
|
||||
expect(frames).toHaveLength(1)
|
||||
const frame = frames[0]
|
||||
if (frame?.type !== 'records') {
|
||||
throw new Error('expected one records frame')
|
||||
}
|
||||
expect(frame.seq).toBe(seq + 1)
|
||||
// Only the renamed tab rows: no tree, binding or workspace record.
|
||||
expect(
|
||||
frame.workspaces.map(({ key, change, ...records }) => [key, change, Object.keys(records)])
|
||||
).toEqual([
|
||||
[GIT, 'records', ['tabs']],
|
||||
[FOLDER, 'records', ['tabs']],
|
||||
[SSH_KEY, 'records', ['tabs']]
|
||||
])
|
||||
expect(
|
||||
frame.workspaces.flatMap((change) => ('tabs' in change ? change.tabs!.set! : []))
|
||||
).toHaveLength(100)
|
||||
expect(check.failures).toEqual([])
|
||||
expect(check.checked()).toBe(1)
|
||||
})
|
||||
|
||||
it('publishes a pane bind as one binding record, and a same-id respawn as one more', async () => {
|
||||
const store = await storeWith({ [GIT]: 2 })
|
||||
const frames: WorkspaceLayoutStreamEvent[] = []
|
||||
streamOver(store).subscribe((frame) => frames.push(frame))
|
||||
const bind = (incarnationId: string) =>
|
||||
store.persistPtyBinding({
|
||||
worktreeId: GIT,
|
||||
tabId: tabIdOf(GIT, 1),
|
||||
leafId: leafOf(1),
|
||||
ptyId: 'pty-1',
|
||||
incarnationId
|
||||
})
|
||||
|
||||
await bind('inc-1')
|
||||
await bind('inc-2')
|
||||
|
||||
const binding = (ptyGeneration: number) => [
|
||||
{
|
||||
key: GIT,
|
||||
change: 'records',
|
||||
bindings: { set: [{ leafId: leafOf(1), ptyId: 'pty-1', ptyGeneration }] }
|
||||
}
|
||||
]
|
||||
expect(frames.map((frame) => frame.type === 'records' && frame.workspaces)).toEqual([
|
||||
binding(1),
|
||||
binding(2)
|
||||
])
|
||||
expect(check.failures).toEqual([])
|
||||
})
|
||||
|
||||
it('sends nothing for an operation that changes no record', async () => {
|
||||
const store = await storeWith({ [GIT]: 1 })
|
||||
const frames: WorkspaceLayoutStreamEvent[] = []
|
||||
streamOver(store).subscribe((frame) => frames.push(frame))
|
||||
|
||||
await renameTabs(store, [{ workspace: GIT, tabId: 'missing', title: 'Gone' }])
|
||||
|
||||
expect(frames).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('a throwing operation listener', () => {
|
||||
it('fails neither the committed operation nor the listeners after it', async () => {
|
||||
const store = await storeWith({ [GIT]: 1 })
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const heard: (readonly string[] | null)[] = []
|
||||
store.onWorkspaceSessionOperationEnded(() => {
|
||||
throw new Error('listener failed')
|
||||
})
|
||||
store.onWorkspaceSessionOperationEnded((hostIds) => heard.push(hostIds))
|
||||
|
||||
await expect(renameTabs(store, titles({ [GIT]: 1 }))).resolves.toEqual({ ok: true, result: {} })
|
||||
|
||||
expect(heard).toEqual([['local']])
|
||||
expect(store.getWorkspaceSession().tabsByWorktree[GIT]![0]!.generatedTitle).toBe('Title 0')
|
||||
warn.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe("main's model check on every frame", () => {
|
||||
it('passes the frame main sent and catches a broken copy of it', async () => {
|
||||
check.uninstall()
|
||||
const store = await storeWith({ [GIT]: 3 })
|
||||
const observed: LayoutFrameObservation[] = []
|
||||
observeWorkspaceLayoutFrames((observation) => observed.push(observation))
|
||||
streamOver(store).subscribe(() => {})
|
||||
|
||||
await renameTabs(store, titles({ [GIT]: 3 }))
|
||||
|
||||
expect(observed).toHaveLength(1)
|
||||
const observation = observed[0]!
|
||||
expect(layoutFrameModelErrors(observation)).toEqual([])
|
||||
const broken = structuredClone(observation.frame)
|
||||
const change = broken.workspaces[0]!
|
||||
if (change.change !== 'records') {
|
||||
throw new Error('expected a records change')
|
||||
}
|
||||
change.tabs!.set!.pop()
|
||||
expect(layoutFrameModelErrors({ ...observation, frame: broken })).toEqual([
|
||||
`frame ${broken.seq}: ${GIT} differs from a fresh projection`
|
||||
])
|
||||
observeWorkspaceLayoutFrames(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe("the dev build's model check", () => {
|
||||
it('skips a frame that only changed live titles, and checks a structural one', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const fresh = vi.fn(() => new Map())
|
||||
const frame = {
|
||||
type: 'records' as const,
|
||||
epoch: 'epoch-1',
|
||||
seq: 1,
|
||||
workspaces: [{ key: GIT, change: 'removed' as const }]
|
||||
}
|
||||
logLayoutFrameModelErrors({ frame, before: new Map(), fresh, titlesOnly: true })
|
||||
expect(fresh).not.toHaveBeenCalled()
|
||||
logLayoutFrameModelErrors({ frame, before: new Map(), fresh, titlesOnly: false })
|
||||
expect(fresh).toHaveBeenCalledTimes(1)
|
||||
warn.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('a headless main', () => {
|
||||
it('hears each operation with no layout subscriber, from one projection it shares', async () => {
|
||||
const store = await storeWith({ [GIT]: 2, [SSH_KEY]: 1 })
|
||||
const getSession = vi.spyOn(store, 'getWorkspaceSession')
|
||||
const stream = streamOver(store)
|
||||
const commits: LayoutOperationCommit[] = []
|
||||
stream.onLayoutOperationCommitted((commit) => commits.push(commit))
|
||||
const first = stream.readPartition('local')
|
||||
|
||||
await renameTabs(store, titles({ [SSH_KEY]: 1 }))
|
||||
|
||||
expect(commits).toEqual([{ hostIds: [SSH] }])
|
||||
// The untouched partition is the same loaded model, read once.
|
||||
getSession.mockClear()
|
||||
expect(stream.readPartition('local')).toBe(first)
|
||||
expect(getSession).not.toHaveBeenCalled()
|
||||
const ssh = stream.readPartition(SSH)!.layout.workspaces[SSH_KEY]!
|
||||
expect(ssh.tabs.map((tab) => tab.generatedTitle)).toEqual(['Title 0'])
|
||||
// The model is the stream's own: a reader that edits it fails.
|
||||
expect(() => {
|
||||
ssh.tabs[0]!.generatedTitle = 'edited'
|
||||
}).toThrow(TypeError)
|
||||
await renameTabs(store, [{ workspace: GIT, tabId: tabIdOf(GIT, 0), title: 'Next' }])
|
||||
expect(stream.readPartition('local')!.layout.workspaces[GIT]!.tabs[0]!.generatedTitle).toBe(
|
||||
'Next'
|
||||
)
|
||||
})
|
||||
|
||||
it('sends a window-style and a phone-style subscriber the same frames', async () => {
|
||||
const store = await storeWith({ [GIT]: 2, [FOLDER]: 1 })
|
||||
const stream = streamOver(store)
|
||||
const window: WorkspaceLayoutStreamEvent[] = []
|
||||
const phone: WorkspaceLayoutStreamEvent[] = []
|
||||
const windowSub = stream.subscribe((frame) => window.push(frame))
|
||||
const phoneSub = stream.subscribe((frame) => phone.push(frame))
|
||||
|
||||
await renameTabs(store, titles({ [GIT]: 2, [FOLDER]: 1 }))
|
||||
await store.persistPtyBinding({
|
||||
worktreeId: GIT,
|
||||
tabId: tabIdOf(GIT, 0),
|
||||
leafId: leafOf(0),
|
||||
ptyId: 'pty-0'
|
||||
})
|
||||
|
||||
expect(window).toHaveLength(2)
|
||||
expect(phone).toEqual(window)
|
||||
expect(phoneSub.snapshot).toEqual(windowSub.snapshot)
|
||||
expect(check.failures).toEqual([])
|
||||
expect(check.checked()).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,197 @@
|
||||
// Each workspace's published layout, projected from the Store's session partitions: a partition
|
||||
// is re-run through the Loader only when a write may have changed it, and each workspace is
|
||||
// published from the partition that owns it.
|
||||
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
|
||||
import { loadWorkspaceLayoutWithoutReport } from '../../shared/workspace-layout/workspace-layout-load'
|
||||
import { stableJson } from '../../shared/workspace-layout/workspace-layout-load-report'
|
||||
import type {
|
||||
LoadedWorkspaceLayout,
|
||||
WorkspaceLayoutLoadContext
|
||||
} from '../../shared/workspace-layout/workspace-layout-load-types'
|
||||
import { nameBasedLoadContext } from '../../shared/workspace-layout/workspace-layout-minted-ids'
|
||||
import {
|
||||
publishWorkspaceLayout,
|
||||
type PublishedWorkspaceLayout
|
||||
} from '../../shared/workspace-layout/workspace-layout-published'
|
||||
import type { RuntimeStore } from './runtime-store-contract'
|
||||
|
||||
export type ProjectedLayout = { layout: PublishedWorkspaceLayout; json: string }
|
||||
|
||||
export type ProjectionStore = Pick<
|
||||
RuntimeStore,
|
||||
'getWorkspaceSession' | 'getWorkspaceSessionHostIds' | 'getPtyBindingGeneration'
|
||||
>
|
||||
|
||||
/** Partitions to re-project; 'all' when a write did not name its partition. */
|
||||
export type DirtyPartitions = Set<ExecutionHostId> | 'all'
|
||||
|
||||
/** The context main loads a partition with: its panes' binding generations ride the leaves. */
|
||||
export function runtimeLayoutLoadContext(
|
||||
store: Pick<RuntimeStore, 'getPtyBindingGeneration'> | null,
|
||||
hostId: ExecutionHostId
|
||||
): WorkspaceLayoutLoadContext {
|
||||
const generationOf = store?.getPtyBindingGeneration
|
||||
// Switch point: a window-owned build publishes no generation.
|
||||
return ORCA_LAYOUT_OWNER_IS_MAIN && generationOf
|
||||
? {
|
||||
...nameBasedLoadContext(),
|
||||
ptyGenerationOf: (leafId) => generationOf.call(store, hostId, leafId)
|
||||
}
|
||||
: nameBasedLoadContext()
|
||||
}
|
||||
|
||||
/** One partition as loaded once: its model, and each workspace published from it on first ask. */
|
||||
class ProjectedPartition {
|
||||
private published: Map<string, ProjectedLayout> | null = null
|
||||
|
||||
constructor(
|
||||
private readonly hostId: ExecutionHostId,
|
||||
readonly loaded: LoadedWorkspaceLayout | null
|
||||
) {}
|
||||
|
||||
/** Every workspace it holds, its own copy of a workspace it does not own included. */
|
||||
workspaces(): Map<string, ProjectedLayout> {
|
||||
if (!this.published) {
|
||||
this.published = new Map()
|
||||
for (const [key, workspace] of Object.entries(this.loaded?.layout.workspaces ?? {})) {
|
||||
const published = publishWorkspaceLayout(workspace, this.hostId)
|
||||
this.published.set(key, { layout: published, json: stableJson(published) })
|
||||
}
|
||||
}
|
||||
return this.published
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkspaceLayoutProjection {
|
||||
/** Workspace key → its published layout, with its JSON for the by-value comparison. */
|
||||
published = new Map<string, ProjectedLayout>()
|
||||
private partitions = new Map<ExecutionHostId, ProjectedPartition>()
|
||||
/** Partitions a write may have changed since they were loaded. */
|
||||
private stale: DirtyPartitions = 'all'
|
||||
/** Its own flag, so a routing warning never hides a later projection or listener failure. */
|
||||
private routingFailureLogged = false
|
||||
|
||||
constructor(
|
||||
private readonly deps: {
|
||||
store: () => ProjectionStore | null
|
||||
/** The partition that owns a workspace key; null when none does. */
|
||||
homeHostId: (key: string) => ExecutionHostId | null
|
||||
}
|
||||
) {}
|
||||
|
||||
/** Forgets every projection: the next read re-loads every partition. */
|
||||
clear(): void {
|
||||
this.published.clear()
|
||||
this.forgetPartitions()
|
||||
}
|
||||
|
||||
/** After a failed projection too: the next read re-loads every partition. */
|
||||
forgetPartitions(): void {
|
||||
this.partitions.clear()
|
||||
this.stale = 'all'
|
||||
}
|
||||
|
||||
/** A write may have changed `hostId`'s partition; null: any partition. */
|
||||
invalidate(hostId: ExecutionHostId | null): void {
|
||||
if (hostId === null || this.stale === 'all') {
|
||||
this.stale = 'all'
|
||||
} else {
|
||||
this.stale.add(hostId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One partition's loaded model, re-loaded only if a write may have changed it; null with no
|
||||
* Store. Only a reader that hears every write (`invalidate`) may `keep` it for the next read.
|
||||
*/
|
||||
loaded(hostId: ExecutionHostId, keep: boolean): LoadedWorkspaceLayout | null {
|
||||
return keep ? this.cachedPartition(hostId).loaded : this.loadPartition(hostId).loaded
|
||||
}
|
||||
|
||||
/** Every workspace as published, each stale partition re-loaded; becomes `published`. */
|
||||
project(): Map<string, ProjectedLayout> {
|
||||
const hostIds = this.deps.store()?.getWorkspaceSessionHostIds?.() ?? []
|
||||
const partitions = new Map(hostIds.map((hostId) => [hostId, this.cachedPartition(hostId)]))
|
||||
this.partitions = partitions
|
||||
this.published = this.route(partitions)
|
||||
return this.published
|
||||
}
|
||||
|
||||
/** What a projection of every partition from scratch publishes now; changes no cache. */
|
||||
projectFresh(): Map<string, ProjectedLayout> {
|
||||
const hostIds = this.deps.store()?.getWorkspaceSessionHostIds?.() ?? []
|
||||
return this.route(new Map(hostIds.map((hostId) => [hostId, this.loadPartition(hostId)])))
|
||||
}
|
||||
|
||||
private cachedPartition(hostId: ExecutionHostId): ProjectedPartition {
|
||||
if (this.stale === 'all') {
|
||||
this.stale = new Set(this.deps.store()?.getWorkspaceSessionHostIds?.() ?? [])
|
||||
this.partitions.clear()
|
||||
}
|
||||
const cached = this.stale.has(hostId) ? undefined : this.partitions.get(hostId)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
const loaded = this.loadPartition(hostId)
|
||||
this.partitions.set(hostId, loaded)
|
||||
this.stale.delete(hostId)
|
||||
return loaded
|
||||
}
|
||||
|
||||
private loadPartition(hostId: ExecutionHostId): ProjectedPartition {
|
||||
const store = this.deps.store()
|
||||
const session = store?.getWorkspaceSession?.(hostId)
|
||||
return new ProjectedPartition(
|
||||
hostId,
|
||||
session
|
||||
? loadWorkspaceLayoutWithoutReport(hostId, session, runtimeLayoutLoadContext(store, hostId))
|
||||
: null
|
||||
)
|
||||
}
|
||||
|
||||
private route(
|
||||
partitions: Map<ExecutionHostId, ProjectedPartition>
|
||||
): Map<string, ProjectedLayout> {
|
||||
const projected = new Map<string, ProjectedLayout>()
|
||||
const unroutable = new Set<string>()
|
||||
for (const [hostId, partition] of partitions) {
|
||||
for (const [key, workspace] of partition.workspaces()) {
|
||||
// Only the owning partition's copy is published; a stray copy elsewhere is not shown.
|
||||
if (this.homeOf(key, unroutable) === hostId) {
|
||||
projected.set(key, workspace)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const key of unroutable) {
|
||||
const kept = this.published.get(key)
|
||||
// Kept as last published: a 'removed' frame would make the window drop its tabs.
|
||||
if (kept && !projected.has(key)) {
|
||||
projected.set(key, kept)
|
||||
}
|
||||
}
|
||||
return projected
|
||||
}
|
||||
|
||||
/**
|
||||
* A workspace the router throws on is not guessed (`repoId::path` repeats across hosts, so a
|
||||
* default could publish another host's copy); it is noted in `unroutable`. It never stops the rest.
|
||||
*/
|
||||
private homeOf(key: string, unroutable: Set<string>): ExecutionHostId | null {
|
||||
try {
|
||||
return this.deps.homeHostId(key)
|
||||
} catch (error) {
|
||||
// Switch point: a window-owned build keeps failing the whole projection, as today.
|
||||
if (!ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
throw error
|
||||
}
|
||||
unroutable.add(key)
|
||||
if (!this.routingFailureLogged) {
|
||||
this.routingFailureLogged = true
|
||||
console.warn('[workspace-layout] layout stream routing failed:', error)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Reads a layout stream the same way in either build: a records frame becomes one event per
|
||||
// workspace it changed, carrying that workspace's whole layout as a window-owned build sends it.
|
||||
|
||||
import type { PublishedWorkspaceLayout } from '../../shared/workspace-layout/workspace-layout-published'
|
||||
import { applyWorkspaceLayoutRecordChange } from '../../shared/workspace-layout/workspace-layout-records'
|
||||
import type {
|
||||
WorkspaceLayoutEvent,
|
||||
WorkspaceLayoutStreamEvent
|
||||
} from '../../shared/workspace-layout/workspace-layout-stream-frames'
|
||||
|
||||
export type LayoutEventCollector = {
|
||||
/** One per workspace changed, in frame order. */
|
||||
events: WorkspaceLayoutEvent[]
|
||||
/** As the stream sent them. */
|
||||
frames: WorkspaceLayoutStreamEvent[]
|
||||
/** What a reader applying every frame to the snapshot holds now. */
|
||||
layouts: Map<string, PublishedWorkspaceLayout>
|
||||
listener: (event: WorkspaceLayoutStreamEvent) => void
|
||||
/** The subscription's snapshot: records frames apply to it. */
|
||||
seed: (snapshot: { key: string; layout: PublishedWorkspaceLayout }[]) => void
|
||||
}
|
||||
|
||||
export function collectLayoutEvents(): LayoutEventCollector {
|
||||
const collector: LayoutEventCollector = {
|
||||
events: [],
|
||||
frames: [],
|
||||
layouts: new Map(),
|
||||
seed: (snapshot) => {
|
||||
collector.layouts = new Map(snapshot.map(({ key, layout }) => [key, layout]))
|
||||
},
|
||||
listener: (event) => {
|
||||
collector.frames.push(event)
|
||||
if (event.type !== 'records') {
|
||||
collector.events.push(event)
|
||||
if (event.type === 'removed') {
|
||||
collector.layouts.delete(event.key)
|
||||
} else {
|
||||
collector.layouts.set(event.key, event.layout)
|
||||
}
|
||||
return
|
||||
}
|
||||
for (const change of event.workspaces) {
|
||||
const layout = applyWorkspaceLayoutRecordChange(collector.layouts.get(change.key), change)
|
||||
if (layout) {
|
||||
collector.layouts.set(change.key, layout)
|
||||
collector.events.push({ type: 'workspace', key: change.key, layout, seq: event.seq })
|
||||
} else {
|
||||
collector.layouts.delete(change.key)
|
||||
collector.events.push({ type: 'removed', key: change.key, seq: event.seq })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return collector
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
SERVER_KEY,
|
||||
SSH_KEY
|
||||
} from '../../shared/workspace-layout/workspace-layout-session.test-fixture'
|
||||
import type { WorkspaceLayoutEvent } from '../../shared/workspace-layout/workspace-layout-stream-frames'
|
||||
import { loadWorkspaceLayout } from '../../shared/workspace-layout/workspace-layout-load'
|
||||
import { nameBasedLoadContext } from '../../shared/workspace-layout/workspace-layout-minted-ids'
|
||||
import {
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
type PublishedWorkspaceLayout
|
||||
} from '../../shared/workspace-layout/workspace-layout-published'
|
||||
import { WorkspaceLayoutStream } from './workspace-layout-stream'
|
||||
import { collectLayoutEvents } from './workspace-layout-stream-events.test-fixture'
|
||||
|
||||
const SSH_HOST: ExecutionHostId = 'ssh:target-1'
|
||||
const SERVER_HOST: ExecutionHostId = 'runtime:env-1'
|
||||
@@ -98,6 +98,14 @@ function setup() {
|
||||
|
||||
const flush = () => new Promise<void>((resolve) => queueMicrotask(resolve))
|
||||
|
||||
/** A subscriber reading each change per workspace, in either build. */
|
||||
function subscribeEvents(stream: WorkspaceLayoutStream) {
|
||||
const collector = collectLayoutEvents()
|
||||
const subscription = stream.subscribe(collector.listener)
|
||||
collector.seed(subscription.snapshot)
|
||||
return { events: collector.events, collector, subscription }
|
||||
}
|
||||
|
||||
describe('WorkspaceLayoutStream', () => {
|
||||
it('does nothing until someone subscribes, and stops when the last one leaves', () => {
|
||||
const { stream, store, listeners } = setup()
|
||||
@@ -133,8 +141,7 @@ describe('WorkspaceLayoutStream', () => {
|
||||
|
||||
it('publishes a workspace when a write changes it by value, and its removal', async () => {
|
||||
const { stream, write } = setup()
|
||||
const events: WorkspaceLayoutEvent[] = []
|
||||
stream.subscribe((event) => events.push(event))
|
||||
const { events } = subscribeEvents(stream)
|
||||
const session = localDesktopSession()
|
||||
write(LOCAL_EXECUTION_HOST_ID, session)
|
||||
await flush()
|
||||
@@ -159,8 +166,7 @@ describe('WorkspaceLayoutStream', () => {
|
||||
|
||||
it('numbers each change frame, so a reply can name the frame holding its write', async () => {
|
||||
const { stream, write } = setup()
|
||||
const events: WorkspaceLayoutEvent[] = []
|
||||
const first = stream.subscribe((event) => events.push(event))
|
||||
const { events, subscription: first } = subscribeEvents(stream)
|
||||
const session = localDesktopSession()
|
||||
write(LOCAL_EXECUTION_HOST_ID, session)
|
||||
await flush()
|
||||
@@ -203,11 +209,10 @@ describe('WorkspaceLayoutStream', () => {
|
||||
it('keeps delivering to every listener when one throws', async () => {
|
||||
const { stream, write } = setup()
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const events: WorkspaceLayoutEvent[] = []
|
||||
stream.subscribe(() => {
|
||||
throw new Error('listener failed')
|
||||
})
|
||||
stream.subscribe((event) => events.push(event))
|
||||
const { events } = subscribeEvents(stream)
|
||||
const session = localDesktopSession()
|
||||
session.tabGroups![GIT_KEY]![0]!.tabOrder.reverse()
|
||||
write(LOCAL_EXECUTION_HOST_ID, session)
|
||||
@@ -244,11 +249,9 @@ describe('WorkspaceLayoutStream', () => {
|
||||
homeHostId: () => LOCAL_EXECUTION_HOST_ID,
|
||||
liveTitle: () => null
|
||||
})
|
||||
const events: WorkspaceLayoutEvent[] = []
|
||||
const { events, subscription } = subscribeEvents(stream)
|
||||
const before = new Map(
|
||||
stream
|
||||
.subscribe((event) => events.push(event))
|
||||
.snapshot.map((entry) => [entry.key, JSON.stringify(entry.layout)])
|
||||
subscription.snapshot.map((entry) => [entry.key, JSON.stringify(entry.layout)])
|
||||
)
|
||||
fake.write(LOCAL_EXECUTION_HOST_ID, rowsOnly(keys.slice(1), ['t1', 't2']))
|
||||
await flush()
|
||||
@@ -286,19 +289,11 @@ describe('WorkspaceLayoutStream', () => {
|
||||
it('publishes what a full re-projection with the load report would, write after write', async () => {
|
||||
const { stream, write, writeUnnamed, removePartition, homes, partitions } = setup()
|
||||
const homeHostId = (key: string) => homes[key] ?? null
|
||||
let cache = new Map<string, PublishedWorkspaceLayout>()
|
||||
const { snapshot } = stream.subscribe((event) => {
|
||||
if (event.type === 'removed') {
|
||||
cache.delete(event.key)
|
||||
} else {
|
||||
cache.set(event.key, event.layout)
|
||||
}
|
||||
})
|
||||
cache = new Map(snapshot.map((entry) => [entry.key, entry.layout]))
|
||||
const { collector } = subscribeEvents(stream)
|
||||
const expectFullProjection = async (): Promise<void> => {
|
||||
await flush()
|
||||
const expected = fullProjection(partitions, homeHostId)
|
||||
expect(Object.fromEntries(cache)).toEqual(Object.fromEntries(expected))
|
||||
expect(Object.fromEntries(collector.layouts)).toEqual(Object.fromEntries(expected))
|
||||
// A late subscriber's snapshot keeps today's order too.
|
||||
const late = stream.subscribe(() => {})
|
||||
expect(late.snapshot.map((entry) => [entry.key, entry.layout])).toEqual(expected)
|
||||
@@ -334,8 +329,7 @@ describe('WorkspaceLayoutStream', () => {
|
||||
it('re-projects every partition after a failed projection', async () => {
|
||||
const { stream, store, write } = setup()
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const events: WorkspaceLayoutEvent[] = []
|
||||
stream.subscribe((event) => events.push(event))
|
||||
const { events } = subscribeEvents(stream)
|
||||
store.getWorkspaceSession.mockImplementationOnce(() => {
|
||||
throw new Error('read failed')
|
||||
})
|
||||
@@ -384,8 +378,10 @@ describe('WorkspaceLayoutStream', () => {
|
||||
|
||||
it('publishes a title change without reading the Store, one frame per burst', async () => {
|
||||
const { stream, store, liveTitles, write } = setup()
|
||||
const events: WorkspaceLayoutEvent[] = []
|
||||
const { snapshot } = stream.subscribe((event) => events.push(event))
|
||||
const {
|
||||
events,
|
||||
subscription: { snapshot }
|
||||
} = subscribeEvents(stream)
|
||||
const [ptyId] = ptyIdsOf(snapshot.find((entry) => entry.key === GIT_KEY)!.layout)
|
||||
store.getWorkspaceSession.mockClear()
|
||||
for (let frame = 0; frame < 50; frame++) {
|
||||
|
||||
@@ -1,50 +1,90 @@
|
||||
// The read-only layout stream behind `layout.subscribe`: each workspace's published layout,
|
||||
// re-projected from the Store's sessions after every write while anyone subscribes. A write that
|
||||
// names its partition re-projects only that partition. Each pane's live terminal title is laid
|
||||
// over the cached projection, so a title change costs no projection. Nothing reads it until a
|
||||
// client opts in; with no subscriber it does no work.
|
||||
// re-projected from the Store's sessions after writes while anyone subscribes. A write that names
|
||||
// its partition re-projects only that partition. Each pane's live terminal title is laid over the
|
||||
// cached projection, so a title change costs no projection. With no subscriber it does no work.
|
||||
//
|
||||
// A layout-owner build publishes once per main operation (one durable mutation: a `layout.apply`,
|
||||
// a binding write, a repair), as one records frame of what changed; writes no operation scopes
|
||||
// (a window save) publish when their task ends. A window-owned build sends a frame per workspace.
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
|
||||
import { loadWorkspaceLayoutWithoutReport } from '../../shared/workspace-layout/workspace-layout-load'
|
||||
import { stableJson } from '../../shared/workspace-layout/workspace-layout-load-report'
|
||||
import type { LoadedWorkspaceLayout } from '../../shared/workspace-layout/workspace-layout-load-types'
|
||||
import type { PublishedWorkspaceLayout } from '../../shared/workspace-layout/workspace-layout-published'
|
||||
import {
|
||||
publishWorkspaceLayout,
|
||||
type PublishedWorkspaceLayout
|
||||
} from '../../shared/workspace-layout/workspace-layout-published'
|
||||
import { nameBasedLoadContext } from '../../shared/workspace-layout/workspace-layout-minted-ids'
|
||||
import type { WorkspaceLayoutEvent } from '../../shared/workspace-layout/workspace-layout-stream-frames'
|
||||
diffPublishedLayouts,
|
||||
indexPublishedLayout,
|
||||
type IndexedLayoutRecords,
|
||||
type WorkspaceLayoutRecordChange
|
||||
} from '../../shared/workspace-layout/workspace-layout-records'
|
||||
import type {
|
||||
WorkspaceLayoutEvent,
|
||||
WorkspaceLayoutStreamEvent
|
||||
} from '../../shared/workspace-layout/workspace-layout-stream-frames'
|
||||
import type { RuntimeStore } from './runtime-store-contract'
|
||||
import { withLiveTitles } from './published-pane-live-titles'
|
||||
import {
|
||||
workspaceLayoutFrameObserver,
|
||||
workspaceLayoutPartitionReadObserver
|
||||
} from './workspace-layout-frame-observer'
|
||||
import {
|
||||
WorkspaceLayoutProjection,
|
||||
type DirtyPartitions,
|
||||
type ProjectionStore
|
||||
} from './workspace-layout-projection'
|
||||
|
||||
type LayoutListener = (event: WorkspaceLayoutEvent) => void
|
||||
type LayoutListener = (event: WorkspaceLayoutStreamEvent) => void
|
||||
|
||||
type Published = { layout: PublishedWorkspaceLayout; json: string }
|
||||
/** A main operation's end, or the end of a task's unscoped writes: the partitions it may have
|
||||
* written; null when any may have changed. */
|
||||
export type LayoutOperationCommit = { hostIds: readonly ExecutionHostId[] | null }
|
||||
|
||||
/** What a workspace was last sent as: its projection's JSON and its panes' live titles. */
|
||||
type Shown = { json: string; liveTitles: string; layout: PublishedWorkspaceLayout }
|
||||
type Shown = {
|
||||
json: string
|
||||
liveTitles: string
|
||||
layout: PublishedWorkspaceLayout
|
||||
/** Its records, indexed when a later layout is first diffed against it; null: it goes whole. */
|
||||
records?: IndexedLayoutRecords | null
|
||||
}
|
||||
|
||||
type StreamStore = Pick<
|
||||
RuntimeStore,
|
||||
'getWorkspaceSession' | 'getWorkspaceSessionHostIds' | 'onWorkspaceSessionWritten'
|
||||
/** What a reader beside the stream uses instead of a Loader pass of its own. */
|
||||
export type WorkspaceLayoutReads = Pick<
|
||||
WorkspaceLayoutStream,
|
||||
'onLayoutOperationCommitted' | 'readPartition'
|
||||
>
|
||||
|
||||
type StreamStore = ProjectionStore &
|
||||
Pick<RuntimeStore, 'onWorkspaceSessionWritten' | 'onWorkspaceSessionOperationEnded'>
|
||||
|
||||
function addDirty(dirty: DirtyPartitions | null, hostId: ExecutionHostId | null): DirtyPartitions {
|
||||
if (hostId === null || dirty === 'all') {
|
||||
return 'all'
|
||||
}
|
||||
return new Set([...(dirty ?? []), hostId])
|
||||
}
|
||||
|
||||
export class WorkspaceLayoutStream {
|
||||
private readonly listeners = new Set<LayoutListener>()
|
||||
/** Workspace key → its published layout, with its JSON for the by-value comparison. */
|
||||
private published = new Map<string, Published>()
|
||||
private readonly commitListeners = new Set<(commit: LayoutOperationCommit) => void>()
|
||||
private readonly projection: WorkspaceLayoutProjection
|
||||
/** Workspace key → what listeners last received for it, live titles included. */
|
||||
private shown = new Map<string, Shown>()
|
||||
/** Partition → every workspace it holds, its own copy of a workspace it does not own included. */
|
||||
private projectedPartitions = new Map<ExecutionHostId, Map<string, Published>>()
|
||||
private stopWatching: (() => void) | null = null
|
||||
/** Partitions to re-project at the next flush; 'all' when a write did not name its partition. */
|
||||
private dirty: Set<ExecutionHostId> | 'all' | null = null
|
||||
/** From the first subscriber to the last: only then is anything projected. */
|
||||
private projecting = false
|
||||
/** A write may have changed what subscribers hold: the next flush re-projects stale partitions. */
|
||||
private dirty = false
|
||||
/** Partitions written since commit listeners last heard. */
|
||||
private touched: DirtyPartitions | null = null
|
||||
private liveTitlesDirty = false
|
||||
private flushQueued = false
|
||||
private failureLogged = false
|
||||
/** Its own flag, so a routing warning never hides a later projection or listener failure. */
|
||||
private routingFailureLogged = false
|
||||
/** The last change frame's position; never reset, so a reply's position outlives resubscribes. */
|
||||
private seq = 0
|
||||
/** Names this stream for the process's life: a reader seeing another one missed frames. */
|
||||
private readonly epoch = ORCA_LAYOUT_OWNER_IS_MAIN ? randomUUID() : undefined
|
||||
|
||||
constructor(
|
||||
private readonly deps: {
|
||||
@@ -54,53 +94,82 @@ export class WorkspaceLayoutStream {
|
||||
/** The title a terminal shows now; null when it has none. */
|
||||
liveTitle: (ptyId: string) => string | null
|
||||
}
|
||||
) {}
|
||||
) {
|
||||
this.projection = new WorkspaceLayoutProjection(deps)
|
||||
}
|
||||
|
||||
/** Every workspace's layout now, then each change until `unsubscribe`. */
|
||||
subscribe(listener: LayoutListener): {
|
||||
snapshot: { key: string; layout: PublishedWorkspaceLayout }[]
|
||||
/** The position the snapshot holds. */
|
||||
seq: number
|
||||
/** A layout-owner build's stream epoch; its records frames carry it. */
|
||||
epoch?: string
|
||||
unsubscribe: () => void
|
||||
} {
|
||||
if (this.listeners.size === 0) {
|
||||
this.stopWatching =
|
||||
this.deps.store()?.onWorkspaceSessionWritten?.((hostId) => this.markDirty(hostId)) ?? null
|
||||
this.published = new Map()
|
||||
if (!this.projecting) {
|
||||
this.projecting = true
|
||||
this.projection.clear()
|
||||
this.shown = new Map()
|
||||
this.projectedPartitions = new Map()
|
||||
this.dirty = 'all'
|
||||
this.dirty = true
|
||||
}
|
||||
// A change not yet published reaches existing listeners first, so the snapshot is current.
|
||||
this.flush()
|
||||
this.listeners.add(listener)
|
||||
this.watch()
|
||||
const snapshot = [...this.shown].map(([key, { layout }]) => ({ key, layout }))
|
||||
return {
|
||||
snapshot,
|
||||
seq: this.seq,
|
||||
...(this.epoch ? { epoch: this.epoch } : {}),
|
||||
unsubscribe: () => {
|
||||
if (!this.listeners.delete(listener) || this.listeners.size > 0) {
|
||||
return
|
||||
}
|
||||
this.stopWatching?.()
|
||||
this.stopWatching = null
|
||||
// A flush already queued finds nothing to do.
|
||||
this.dirty = null
|
||||
this.projecting = false
|
||||
// A flush already queued finds nothing to project.
|
||||
this.dirty = false
|
||||
this.liveTitlesDirty = false
|
||||
this.published.clear()
|
||||
this.shown.clear()
|
||||
this.projectedPartitions.clear()
|
||||
this.watch()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Each main operation's end once its writes are in, with the partitions it may have written,
|
||||
* and each task's unscoped writes. Heard with or without a layout subscriber.
|
||||
*/
|
||||
onLayoutOperationCommitted(listener: (commit: LayoutOperationCommit) => void): () => void {
|
||||
this.commitListeners.add(listener)
|
||||
this.watch()
|
||||
return () => {
|
||||
this.commitListeners.delete(listener)
|
||||
this.watch()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One session partition's loaded model (layout, view and facts), the one the stream publishes
|
||||
* from: shared while anyone listens (cached until a write may change it), else loaded per call.
|
||||
* READ-ONLY: the stream and every other reader see the same object; tests freeze it. Null with
|
||||
* no Store.
|
||||
*/
|
||||
readPartition(hostId: ExecutionHostId): LoadedWorkspaceLayout | null {
|
||||
const loaded = this.projection.loaded(hostId, this.stopWatching !== null)
|
||||
if (loaded) {
|
||||
workspaceLayoutPartitionReadObserver()?.(loaded)
|
||||
}
|
||||
return loaded
|
||||
}
|
||||
|
||||
/** Some terminal's title may have changed; republishes only the panes whose title did. */
|
||||
markLiveTitlesDirty(): void {
|
||||
if (this.listeners.size === 0) {
|
||||
if (!this.projecting) {
|
||||
return
|
||||
}
|
||||
this.schedule()
|
||||
this.liveTitlesDirty = true
|
||||
this.schedule()
|
||||
}
|
||||
|
||||
/** The position holding every write made so far: a pending flush is sent first. */
|
||||
@@ -109,38 +178,74 @@ export class WorkspaceLayoutStream {
|
||||
return this.seq
|
||||
}
|
||||
|
||||
private watch(): void {
|
||||
const wanted = this.listeners.size > 0 || this.commitListeners.size > 0
|
||||
if (wanted && !this.stopWatching) {
|
||||
const store = this.deps.store()
|
||||
const stopWrites = store?.onWorkspaceSessionWritten?.((hostId) => this.markDirty(hostId))
|
||||
// Switch point: a window-owned build publishes when the writes' task ends, as before.
|
||||
const stopOperations = ORCA_LAYOUT_OWNER_IS_MAIN
|
||||
? store?.onWorkspaceSessionOperationEnded?.(() => this.flush())
|
||||
: undefined
|
||||
this.stopWatching =
|
||||
stopWrites || stopOperations
|
||||
? () => {
|
||||
stopWrites?.()
|
||||
stopOperations?.()
|
||||
}
|
||||
: null
|
||||
} else if (!wanted && this.stopWatching) {
|
||||
this.stopWatching()
|
||||
this.stopWatching = null
|
||||
// Unwatched writes would go unseen: nothing projected may be read again.
|
||||
this.projection.clear()
|
||||
}
|
||||
}
|
||||
|
||||
private schedule(): void {
|
||||
if (this.dirty === null && !this.liveTitlesDirty) {
|
||||
// Coalesces a burst of writes and title changes in one task into one flush.
|
||||
queueMicrotask(() => this.flush())
|
||||
if (!this.flushQueued) {
|
||||
this.flushQueued = true
|
||||
// Coalesces a burst of unscoped writes and title changes in one task into one flush.
|
||||
queueMicrotask(() => {
|
||||
this.flushQueued = false
|
||||
this.flush()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private markDirty(hostId: ExecutionHostId | null): void {
|
||||
this.schedule()
|
||||
if (hostId === null) {
|
||||
this.dirty = 'all'
|
||||
} else if (this.dirty === null) {
|
||||
this.dirty = new Set([hostId])
|
||||
} else if (this.dirty !== 'all') {
|
||||
this.dirty.add(hostId)
|
||||
this.projection.invalidate(hostId)
|
||||
this.dirty ||= this.projecting
|
||||
if (this.commitListeners.size > 0) {
|
||||
this.touched = addDirty(this.touched, hostId)
|
||||
}
|
||||
this.schedule()
|
||||
}
|
||||
|
||||
private flush(): void {
|
||||
const dirty = this.dirty
|
||||
if (dirty === null && !this.liveTitlesDirty) {
|
||||
return
|
||||
const { dirty, touched } = this
|
||||
this.touched = null
|
||||
if (dirty || this.liveTitlesDirty) {
|
||||
this.dirty = false
|
||||
this.liveTitlesDirty = false
|
||||
// A failed projection is logged once; it never reaches the write that notified it.
|
||||
try {
|
||||
this.reconcile(dirty)
|
||||
} catch (error) {
|
||||
// The cache may be part-updated; the next write re-projects every partition.
|
||||
this.projection.forgetPartitions()
|
||||
this.logFailure('projection', error)
|
||||
}
|
||||
}
|
||||
this.dirty = null
|
||||
this.liveTitlesDirty = false
|
||||
// A failed projection is logged once; it never reaches the write that notified it.
|
||||
try {
|
||||
this.reconcile(dirty)
|
||||
} catch (error) {
|
||||
// The cache may be part-updated; the next write re-projects every partition.
|
||||
this.projectedPartitions.clear()
|
||||
this.logFailure('projection', error)
|
||||
if (touched !== null) {
|
||||
const commit = { hostIds: touched === 'all' ? null : [...touched] }
|
||||
for (const listener of this.commitListeners) {
|
||||
try {
|
||||
listener(commit)
|
||||
} catch (error) {
|
||||
this.logFailure('commit listener', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,70 +256,6 @@ export class WorkspaceLayoutStream {
|
||||
}
|
||||
}
|
||||
|
||||
private projectPartition(hostId: ExecutionHostId): Map<string, Published> {
|
||||
const projected = new Map<string, Published>()
|
||||
const session = this.deps.store()?.getWorkspaceSession?.(hostId)
|
||||
if (!session) {
|
||||
return projected
|
||||
}
|
||||
const { layout } = loadWorkspaceLayoutWithoutReport(hostId, session, nameBasedLoadContext())
|
||||
for (const [key, workspace] of Object.entries(layout.workspaces)) {
|
||||
const published = publishWorkspaceLayout(workspace, hostId)
|
||||
projected.set(key, { layout: published, json: stableJson(published) })
|
||||
}
|
||||
return projected
|
||||
}
|
||||
|
||||
private project(dirty: Set<ExecutionHostId> | 'all'): Map<string, Published> {
|
||||
const hostIds = this.deps.store()?.getWorkspaceSessionHostIds?.() ?? []
|
||||
const partitions = new Map<ExecutionHostId, Map<string, Published>>()
|
||||
for (const hostId of hostIds) {
|
||||
const cached =
|
||||
dirty === 'all' || dirty.has(hostId) ? undefined : this.projectedPartitions.get(hostId)
|
||||
partitions.set(hostId, cached ?? this.projectPartition(hostId))
|
||||
}
|
||||
this.projectedPartitions = partitions
|
||||
const projected = new Map<string, Published>()
|
||||
const unroutable = new Set<string>()
|
||||
for (const [hostId, workspaces] of partitions) {
|
||||
for (const [key, workspace] of workspaces) {
|
||||
// Only the owning partition's copy is published; a stray copy elsewhere is not shown.
|
||||
if (this.homeOf(key, unroutable) === hostId) {
|
||||
projected.set(key, workspace)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const key of unroutable) {
|
||||
const kept = this.published.get(key)
|
||||
// Kept as last published: a 'removed' frame would make the window drop its tabs.
|
||||
if (kept && !projected.has(key)) {
|
||||
projected.set(key, kept)
|
||||
}
|
||||
}
|
||||
return projected
|
||||
}
|
||||
|
||||
/**
|
||||
* A workspace the router throws on is not guessed (`repoId::path` repeats across hosts, so a
|
||||
* default could publish another host's copy); it is noted in `unroutable`. It never stops the rest.
|
||||
*/
|
||||
private homeOf(key: string, unroutable: Set<string>): ExecutionHostId | null {
|
||||
try {
|
||||
return this.deps.homeHostId(key)
|
||||
} catch (error) {
|
||||
// Switch point: a window-owned build keeps failing the whole projection, as today.
|
||||
if (!ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
throw error
|
||||
}
|
||||
unroutable.add(key)
|
||||
if (!this.routingFailureLogged) {
|
||||
this.routingFailureLogged = true
|
||||
console.warn('[workspace-layout] layout stream routing failed:', error)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Every pane's live title, in tab and pane order; null for a pane with none. */
|
||||
private liveTitlesOf(layout: PublishedWorkspaceLayout): (string | null)[] {
|
||||
return layout.tabs.flatMap((tab) =>
|
||||
@@ -224,67 +265,102 @@ export class WorkspaceLayoutStream {
|
||||
)
|
||||
}
|
||||
|
||||
private reconcile(dirty: Set<ExecutionHostId> | 'all' | null): void {
|
||||
const next = dirty === null ? this.published : this.project(dirty)
|
||||
const events: WorkspaceLayoutEvent[] = []
|
||||
private reconcile(reproject: boolean): void {
|
||||
const next = reproject ? this.projection.project() : this.projection.published
|
||||
const removed: string[] = []
|
||||
for (const key of this.shown.keys()) {
|
||||
if (!next.has(key)) {
|
||||
events.push({ type: 'removed', key })
|
||||
removed.push(key)
|
||||
}
|
||||
}
|
||||
const before = this.shown
|
||||
const shown = new Map<string, Shown>()
|
||||
const changed: string[] = []
|
||||
for (const [key, { layout, json }] of next) {
|
||||
const titles = this.liveTitlesOf(layout)
|
||||
const liveTitles = JSON.stringify(titles)
|
||||
const previous = this.shown.get(key)
|
||||
const previous = before.get(key)
|
||||
if (previous?.json === json && previous.liveTitles === liveTitles) {
|
||||
shown.set(key, previous)
|
||||
continue
|
||||
}
|
||||
const withTitles = withLiveTitles(layout, titles)
|
||||
shown.set(key, { json, liveTitles, layout: withTitles })
|
||||
events.push({ type: 'workspace', key, layout: withTitles })
|
||||
shown.set(key, { json, liveTitles, layout: withLiveTitles(layout, titles) })
|
||||
changed.push(key)
|
||||
}
|
||||
this.published = next
|
||||
this.shown = shown
|
||||
// Switch point: a window-owned build sends each workspace whole, one frame each.
|
||||
if (!ORCA_LAYOUT_OWNER_IS_MAIN || !this.epoch) {
|
||||
this.deliverEach([
|
||||
...removed.map((key): WorkspaceLayoutEvent => ({ type: 'removed', key })),
|
||||
...changed.map((key): WorkspaceLayoutEvent => ({
|
||||
type: 'workspace',
|
||||
key,
|
||||
layout: shown.get(key)!.layout
|
||||
}))
|
||||
])
|
||||
return
|
||||
}
|
||||
// The first subscriber's own projection reaches no one: its snapshot holds it.
|
||||
if (this.listeners.size === 0) {
|
||||
return
|
||||
}
|
||||
const workspaces: WorkspaceLayoutRecordChange[] = removed.map((key) => ({
|
||||
key,
|
||||
change: 'removed'
|
||||
}))
|
||||
for (const key of changed) {
|
||||
const previous = before.get(key)
|
||||
const current = shown.get(key)!
|
||||
const change = previous
|
||||
? diffPublishedLayouts(key, recordsOf(previous), recordsOf(current), current.layout)
|
||||
: { key, change: 'layout' as const, layout: current.layout }
|
||||
if (change) {
|
||||
workspaces.push(change)
|
||||
}
|
||||
}
|
||||
if (workspaces.length === 0) {
|
||||
return
|
||||
}
|
||||
const frame = { type: 'records' as const, epoch: this.epoch, seq: ++this.seq, workspaces }
|
||||
this.deliver(frame)
|
||||
workspaceLayoutFrameObserver()?.({
|
||||
frame,
|
||||
before: new Map([...before].map(([key, { layout }]) => [key, layout])),
|
||||
fresh: () => this.freshLayouts(),
|
||||
titlesOnly: !reproject
|
||||
})
|
||||
}
|
||||
|
||||
private freshLayouts(): Map<string, PublishedWorkspaceLayout> {
|
||||
const fresh = new Map<string, PublishedWorkspaceLayout>()
|
||||
for (const [key, { layout }] of this.projection.projectFresh()) {
|
||||
fresh.set(key, withLiveTitles(layout, this.liveTitlesOf(layout)))
|
||||
}
|
||||
return fresh
|
||||
}
|
||||
|
||||
private deliverEach(events: WorkspaceLayoutEvent[]): void {
|
||||
for (const unsequenced of events) {
|
||||
// Numbered as sent, so a failed projection never skips a position a reply could name.
|
||||
const event = { ...unsequenced, seq: ++this.seq }
|
||||
for (const listener of this.listeners) {
|
||||
// One failing listener must not cost the others an event the stream has now published.
|
||||
try {
|
||||
listener(event)
|
||||
} catch (error) {
|
||||
this.logFailure('listener', error)
|
||||
}
|
||||
this.deliver({ ...unsequenced, seq: ++this.seq })
|
||||
}
|
||||
}
|
||||
|
||||
private deliver(event: WorkspaceLayoutStreamEvent): void {
|
||||
for (const listener of this.listeners) {
|
||||
// One failing listener must not cost the others an event the stream has now published.
|
||||
try {
|
||||
listener(event)
|
||||
} catch (error) {
|
||||
this.logFailure('listener', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function withLiveTitles(
|
||||
layout: PublishedWorkspaceLayout,
|
||||
titles: (string | null)[]
|
||||
): PublishedWorkspaceLayout {
|
||||
if (titles.every((title) => title === null)) {
|
||||
return layout
|
||||
}
|
||||
let index = 0
|
||||
return {
|
||||
...layout,
|
||||
tabs: layout.tabs.map((tab) =>
|
||||
tab.terminal
|
||||
? {
|
||||
...tab,
|
||||
terminal: {
|
||||
...tab.terminal,
|
||||
panes: tab.terminal.panes.map((pane) => {
|
||||
const liveTitle = titles[index++]
|
||||
return liveTitle === null ? pane : { ...pane, liveTitle }
|
||||
})
|
||||
}
|
||||
}
|
||||
: tab
|
||||
)
|
||||
function recordsOf(shown: Shown): IndexedLayoutRecords | null {
|
||||
if (shown.records === undefined) {
|
||||
shown.records = indexPublishedLayout(shown.layout)
|
||||
}
|
||||
return shown.records
|
||||
}
|
||||
|
||||
@@ -166,13 +166,12 @@ describe.each(targets)('runtime layout client over the %s transport', (_name, ta
|
||||
expect(stream.unsubscribed).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores a change before the snapshot, unknown frame types and malformed frames', async () => {
|
||||
it('ignores a change before the snapshot and unknown frame types', async () => {
|
||||
subscribe()
|
||||
const stream = await opened(1)
|
||||
stream.send({ type: 'workspace', key: 'a', layout: layout('a') })
|
||||
stream.send(snapshot())
|
||||
stream.send({ type: 'navigate', request: { focusTab: 't1' } })
|
||||
stream.send({ type: 'workspace', key: 'a', layout: { worktreeId: 'a' } })
|
||||
expect(cache).toEqual({})
|
||||
expect(stream.unsubscribed).toBe(false)
|
||||
})
|
||||
@@ -194,13 +193,17 @@ describe.each(targets)('runtime layout client over the %s transport', (_name, ta
|
||||
const stream = await opened(1)
|
||||
stream.send({ ...snapshot(), seq: 3 })
|
||||
stream.send({ type: 'navigate', request: {}, seq: 4 })
|
||||
stream.send({ type: 'workspace', key: 'a', layout: { worktreeId: 'a' }, seq: 5 })
|
||||
throwOnFrame = true
|
||||
expect(() => stream.send({ type: 'removed', key: 'a', seq: 6 })).toThrow('reader failed')
|
||||
expect(() => stream.send({ type: 'removed', key: 'a', seq: 5 })).toThrow('reader failed')
|
||||
// A frame with no position, as an older host sends, reports none.
|
||||
throwOnFrame = false
|
||||
stream.send({ type: 'removed', key: 'a' })
|
||||
expect(positions).toEqual([3, 4, 5, 6])
|
||||
expect(positions).toEqual([3, 4, 5])
|
||||
// A malformed frame sends the stream back for a snapshot, whose position covers it.
|
||||
stream.send({ type: 'workspace', key: 'a', layout: { worktreeId: 'a' }, seq: 6 })
|
||||
expect(positions).toEqual([3, 4, 5])
|
||||
;(await opened(2)).send({ ...snapshot(), seq: 7 })
|
||||
expect(positions).toEqual([3, 4, 5, 7])
|
||||
})
|
||||
|
||||
it.skipIf(target.kind === 'local')(
|
||||
@@ -296,6 +299,174 @@ describe.each(targets)('runtime layout client over the %s transport', (_name, ta
|
||||
})
|
||||
})
|
||||
|
||||
describe.each(targets)(
|
||||
'a records stream over the %s transport that misses a frame',
|
||||
(_name, target) => {
|
||||
let host: FakeHost
|
||||
let cache: WorkspaceLayoutCache
|
||||
let statuses: RuntimeLayoutStreamStatus[]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
host = installFakeHost(target)
|
||||
cache = EMPTY_WORKSPACE_LAYOUT_CACHE
|
||||
statuses = []
|
||||
subscribeRuntimeLayout(
|
||||
target,
|
||||
{},
|
||||
(frame) => {
|
||||
cache = applyLayoutFrame(cache, frame)
|
||||
},
|
||||
(next) => statuses.push(next)
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
async function opened(count: number): Promise<FakeStream> {
|
||||
await vi.waitFor(() => expect(host.streams).toHaveLength(count))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
return host.streams[count - 1]!
|
||||
}
|
||||
|
||||
const snapshot = (epoch: string, seq: number, worktreeIds: string[]) => ({
|
||||
type: 'snapshot',
|
||||
subscriptionId: 'layout-1',
|
||||
epoch,
|
||||
seq,
|
||||
workspaces: worktreeIds.map((id) => ({ key: id, layout: layout(id) }))
|
||||
})
|
||||
|
||||
/** One operation's frame: workspace `key`'s groups now hold `tabIds`. */
|
||||
const regrouped = (epoch: string, seq: number, key: string, tabIds: string[]) => ({
|
||||
type: 'records',
|
||||
epoch,
|
||||
seq,
|
||||
workspaces: [
|
||||
{
|
||||
key,
|
||||
change: 'records',
|
||||
workspace: { worktreeId: key, groups: [{ id: `group-${key}`, tabIds }] }
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
it('applies each records frame in sequence to the snapshot', async () => {
|
||||
const stream = await opened(1)
|
||||
stream.send(snapshot('epoch-1', 3, ['a', 'b']))
|
||||
stream.send(regrouped('epoch-1', 4, 'a', ['t1']))
|
||||
stream.send(regrouped('epoch-1', 5, 'b', ['t2']))
|
||||
expect(cache).toEqual({ a: layout('a', ['t1']), b: layout('b', ['t2']) })
|
||||
expect(stream.unsubscribed).toBe(false)
|
||||
expect(host.streams).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('counts a skipped frame of a newer type as a position, so the next frame is no gap', async () => {
|
||||
const stream = await opened(1)
|
||||
stream.send(snapshot('epoch-1', 3, ['a']))
|
||||
stream.send({ type: 'navigate', request: {}, seq: 4 })
|
||||
stream.send(regrouped('epoch-1', 5, 'a', ['t1']))
|
||||
expect(cache).toEqual({ a: layout('a', ['t1']) })
|
||||
expect(host.streams).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('stops resubscribing at once when every fresh stream fails, and backs off', async () => {
|
||||
const bad = { type: 'records', epoch: 'epoch-1', seq: 4, workspaces: 'a' }
|
||||
for (let count = 1; count <= 4; count += 1) {
|
||||
const stream = await opened(count)
|
||||
stream.send(snapshot('epoch-1', 3, ['a']))
|
||||
stream.send(bad)
|
||||
}
|
||||
// Three immediate resubscribes; the fourth failure takes the loss path.
|
||||
expect(statuses.at(-1)).toEqual({ state: 'lost' })
|
||||
expect(host.streams).toHaveLength(4)
|
||||
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
|
||||
}
|
||||
// Still failing: the reopened stream's snapshot keeps the backoff growing.
|
||||
const fifth = await opened(5)
|
||||
fifth.send(snapshot('epoch-1', 3, ['a']))
|
||||
fifth.send(bad)
|
||||
await vi.advanceTimersByTimeAsync(1999)
|
||||
expect(host.streams).toHaveLength(5)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(host.streams).toHaveLength(6)
|
||||
// A clean change ends the storm: the next drop reopens after 1 s again.
|
||||
const sixth = await opened(6)
|
||||
sixth.send(snapshot('epoch-1', 3, ['a']))
|
||||
sixth.send(regrouped('epoch-1', 4, 'a', ['t1']))
|
||||
sixth.drop()
|
||||
await vi.advanceTimersByTimeAsync(999)
|
||||
expect(host.streams).toHaveLength(6)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(host.streams).toHaveLength(7)
|
||||
})
|
||||
|
||||
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']))
|
||||
const second = await opened(2)
|
||||
second.send(snapshot('epoch-1', 9, ['a']))
|
||||
// Two unrelated drops, each followed by a healthy snapshot, each reopen after 1 s.
|
||||
for (let count = 3; count <= 4; count += 1) {
|
||||
host.streams.at(-1)!.drop()
|
||||
await vi.advanceTimersByTimeAsync(999)
|
||||
expect(host.streams).toHaveLength(count - 1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(host.streams).toHaveLength(count)
|
||||
;(await opened(count)).send(snapshot('epoch-1', 9, ['a']))
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['an epoch change', regrouped('epoch-2', 4, 'a', ['t1'])],
|
||||
['a position that skips one', regrouped('epoch-1', 5, 'a', ['t1'])],
|
||||
[
|
||||
'an unreadable records frame',
|
||||
{ type: 'records', epoch: 'epoch-1', seq: 4, workspaces: 'a' }
|
||||
],
|
||||
['records for a workspace never shown', regrouped('epoch-1', 4, 'z', ['t1'])]
|
||||
])('resubscribes exactly once after %s and takes the fresh snapshot', async (_case, frame) => {
|
||||
const first = await opened(1)
|
||||
first.send(snapshot('epoch-1', 3, ['a', 'b']))
|
||||
|
||||
first.send(frame)
|
||||
expect(first.unsubscribed).toBe(true)
|
||||
const second = await opened(2)
|
||||
// A frame the dropped stream still delivers changes nothing.
|
||||
first.send(regrouped('epoch-1', 4, 'a', ['late']))
|
||||
second.send(snapshot('epoch-2', 9, ['a']))
|
||||
|
||||
expect(cache).toEqual({ a: layout('a') })
|
||||
second.send(regrouped('epoch-2', 10, 'a', ['t1']))
|
||||
expect(cache).toEqual({ a: layout('a', ['t1']) })
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
expect(host.streams).toHaveLength(2)
|
||||
expect(second.unsubscribed).toBe(false)
|
||||
// The window never sees the stream as lost: it resubscribed at once.
|
||||
expect(statuses.map((status) => status.state)).toEqual([
|
||||
'connecting',
|
||||
'live',
|
||||
'connecting',
|
||||
'live'
|
||||
])
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
describe('the local layout stream after a loss', () => {
|
||||
let host: FakeHost
|
||||
let cache: WorkspaceLayoutCache
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { hasRuntimeRpcErrorCode } from '../../../shared/runtime-rpc-error-code'
|
||||
import {
|
||||
isKnownWorkspaceLayoutFrameType,
|
||||
readWorkspaceLayoutFramePosition,
|
||||
readWorkspaceLayoutStreamFrame,
|
||||
type WorkspaceLayoutChangeFrame,
|
||||
@@ -25,18 +26,24 @@ 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
|
||||
/** 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export function subscribeRuntimeLayout(
|
||||
target: RuntimeClientTarget,
|
||||
params: { workspaces?: 'all' | string[] },
|
||||
onFrame: (frame: WorkspaceLayoutChangeFrame) => void,
|
||||
onStatus: (status: RuntimeLayoutStreamStatus) => void,
|
||||
/** Each frame's stream position, an unreadable frame's too: a reply may name any frame. */
|
||||
/** Each frame's stream position, a skipped frame's too: a reply may name any frame. A frame
|
||||
* that sends the stream back for a snapshot reports none; the snapshot's position covers it. */
|
||||
onPosition?: (seq: number) => void
|
||||
): { close: () => void } {
|
||||
// Bumped whenever a stream is given up, so its late callbacks change nothing.
|
||||
@@ -45,6 +52,13 @@ export function subscribeRuntimeLayout(
|
||||
let state: RuntimeLayoutStreamStatus['state'] = 'connecting'
|
||||
let reopenTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let reopenDelay = LOCAL_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
|
||||
/** Resubscribes since a records frame last applied cleanly. */
|
||||
let resyncs = 0
|
||||
/** The budget ran out: until a records frame applies cleanly, snapshots keep the backoff. */
|
||||
let storm = false
|
||||
|
||||
const setStatus = (status: RuntimeLayoutStreamStatus): void => {
|
||||
state = status.state
|
||||
@@ -82,6 +96,23 @@ export function subscribeRuntimeLayout(
|
||||
}
|
||||
}
|
||||
|
||||
// Before the snapshot the host itself is suspect, so the loss path's backoff applies.
|
||||
const resync = (streamGeneration: number, reason: unknown): void => {
|
||||
if (streamGeneration !== generation) {
|
||||
return
|
||||
}
|
||||
// A frame that fails every time must not resubscribe without limit.
|
||||
if (state !== 'live' || resyncs >= RESYNC_BUDGET) {
|
||||
storm ||= resyncs >= RESYNC_BUDGET
|
||||
lose(streamGeneration, reason)
|
||||
return
|
||||
}
|
||||
resyncs += 1
|
||||
console.warn('[workspace-layout] layout stream resubscribes:', reason)
|
||||
release()
|
||||
void open()
|
||||
}
|
||||
|
||||
const receive = (streamGeneration: number, value: unknown): void => {
|
||||
if (streamGeneration !== generation) {
|
||||
return
|
||||
@@ -91,7 +122,19 @@ export function subscribeRuntimeLayout(
|
||||
}
|
||||
const seq = readWorkspaceLayoutFramePosition(value)
|
||||
try {
|
||||
deliver(streamGeneration, readWorkspaceLayoutStreamFrame(value))
|
||||
const frame = readWorkspaceLayoutStreamFrame(value)
|
||||
// A newer host's frame type is skipped; a malformed frame of a known type lost a change.
|
||||
if (frame || isKnownWorkspaceLayoutFrameType(value)) {
|
||||
deliver(streamGeneration, frame)
|
||||
} else if (
|
||||
state === 'live' &&
|
||||
seq !== undefined &&
|
||||
lastSeq !== undefined &&
|
||||
seq === lastSeq + 1
|
||||
) {
|
||||
// A skipped frame still holds its position, so the next one is no gap.
|
||||
lastSeq = seq
|
||||
}
|
||||
} finally {
|
||||
if (seq !== undefined && streamGeneration === generation) {
|
||||
onPosition?.(seq)
|
||||
@@ -100,8 +143,8 @@ export function subscribeRuntimeLayout(
|
||||
}
|
||||
|
||||
const deliver = (streamGeneration: number, frame: WorkspaceLayoutStreamFrame | null): void => {
|
||||
// Null: a newer host's frame type or an unreadable frame; neither may change the cache.
|
||||
if (!frame) {
|
||||
resync(streamGeneration, new Error('unreadable layout frame'))
|
||||
return
|
||||
}
|
||||
if (frame.type === 'end') {
|
||||
@@ -109,11 +152,34 @@ export function subscribeRuntimeLayout(
|
||||
return
|
||||
}
|
||||
if (frame.type === 'snapshot') {
|
||||
reopenDelay = LOCAL_REOPEN_DELAY_MS
|
||||
// During a resync storm the backoff keeps growing; a snapshot alone proves nothing.
|
||||
if (!storm) {
|
||||
reopenDelay = LOCAL_REOPEN_DELAY_MS
|
||||
}
|
||||
epoch = frame.epoch
|
||||
lastSeq = frame.seq
|
||||
setStatus({ state: 'live' })
|
||||
} else if (state !== 'live') {
|
||||
// The host sends the snapshot first; a change before it has nothing to replace.
|
||||
return
|
||||
} else if (frame.type === 'records') {
|
||||
if (frame.epoch !== epoch || lastSeq === undefined || frame.seq !== lastSeq + 1) {
|
||||
resync(streamGeneration, new Error(`layout frame ${frame.seq} after ${lastSeq ?? 'none'}`))
|
||||
return
|
||||
}
|
||||
lastSeq = frame.seq
|
||||
try {
|
||||
onFrame(frame)
|
||||
} catch (error) {
|
||||
resync(streamGeneration, error)
|
||||
return
|
||||
}
|
||||
resyncs = 0
|
||||
if (storm) {
|
||||
storm = false
|
||||
reopenDelay = LOCAL_REOPEN_DELAY_MS
|
||||
}
|
||||
return
|
||||
}
|
||||
onFrame(frame)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as AgentStatusModule from '@/lib/agent-status'
|
||||
import type * as WorkspaceSessionModule from '@/lib/workspace-session'
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
import { loadWorkspaceLayoutWithoutReport } from '../../../shared/workspace-layout/workspace-layout-load'
|
||||
import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids'
|
||||
import { localDesktopSession } from '../../../shared/workspace-layout/workspace-layout-profile.test-fixture'
|
||||
import {
|
||||
publishWorkspaceLayout,
|
||||
type PublishedWorkspaceLayout
|
||||
} from '../../../shared/workspace-layout/workspace-layout-published'
|
||||
import type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames'
|
||||
import {
|
||||
GIT_KEY,
|
||||
leaf
|
||||
} from '../../../shared/workspace-layout/workspace-layout-session.test-fixture'
|
||||
import { buildWorkspaceSessionPayload } from '@/lib/workspace-session'
|
||||
import { bootWindowFromPartitions } from '../app-shell/startup-window-view.test-fixture'
|
||||
import { createStoreSessionMockApi } from '../store/slices/store-session-test-harness'
|
||||
import { createTestStore } from '../store/slices/store-test-helpers'
|
||||
import type { LayoutFrameSlices } from '../store/workspace-layout-frame-slices'
|
||||
import { endLayoutFramePositions } from './layout-frame-position'
|
||||
import { holdTabPaneTree } from './layout-owner-pane-intents'
|
||||
import { holdDrawnTabs } from './layout-owner-drawn-tab-holds'
|
||||
import { openWorkspaceLayoutFrameSync } from './workspace-layout-frame-sync'
|
||||
|
||||
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) }
|
||||
})
|
||||
// The rebuild reads the window's session back first: a call means a workspace rebuild ran.
|
||||
vi.mock('@/lib/workspace-session', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof WorkspaceSessionModule>()
|
||||
return { ...actual, buildWorkspaceSessionPayload: vi.fn(actual.buildWorkspaceSessionPayload) }
|
||||
})
|
||||
|
||||
const stream = vi.hoisted(
|
||||
(): { onFrame: ((frame: WorkspaceLayoutChangeFrame) => void) | null } => ({ onFrame: null })
|
||||
)
|
||||
vi.mock('./runtime-layout-client', () => ({
|
||||
subscribeRuntimeLayout: (
|
||||
_target: unknown,
|
||||
_params: unknown,
|
||||
onFrame: (frame: WorkspaceLayoutChangeFrame) => void
|
||||
) => {
|
||||
stream.onFrame = onFrame
|
||||
return { close: () => {} }
|
||||
}
|
||||
}))
|
||||
|
||||
createStoreSessionMockApi()
|
||||
|
||||
const SLICES: (keyof LayoutFrameSlices)[] = [
|
||||
'tabsByWorktree',
|
||||
'terminalLayoutsByTabId',
|
||||
'activeTabIdByWorktree',
|
||||
'unifiedTabsByWorktree',
|
||||
'groupsByWorktree',
|
||||
'activeGroupIdByWorktree',
|
||||
'layoutByWorktree',
|
||||
'openFiles',
|
||||
'activeFileIdByWorktree',
|
||||
'browserTabsByWorktree',
|
||||
'activeBrowserTabIdByWorktree',
|
||||
'activeTabTypeByWorktree'
|
||||
]
|
||||
|
||||
const settle = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
function publishedOf(session: WorkspaceSessionState): PublishedWorkspaceLayout {
|
||||
const { layout } = loadWorkspaceLayoutWithoutReport('local', session, nameBasedLoadContext())
|
||||
return publishWorkspaceLayout(layout.workspaces[GIT_KEY]!, 'local')
|
||||
}
|
||||
|
||||
/** The frame with one pane's terminal binding changed; absent `ptyId` unbinds it. */
|
||||
function rebound(
|
||||
layout: PublishedWorkspaceLayout,
|
||||
tabId: string,
|
||||
leafId: string,
|
||||
ptyId: string | undefined
|
||||
): PublishedWorkspaceLayout {
|
||||
const next = structuredClone(layout)
|
||||
const pane = next.tabs
|
||||
.find((tab) => tab.entityId === tabId)!
|
||||
.terminal!.panes.find((candidate) => candidate.leafId === leafId)!
|
||||
if (ptyId === undefined) {
|
||||
delete pane.ptyId
|
||||
} else {
|
||||
pane.ptyId = ptyId
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/** Boots a window shown from the session's frame; each later frame arrives as main sends it. */
|
||||
async function boot() {
|
||||
const store = createTestStore()
|
||||
const session = localDesktopSession()
|
||||
await bootWindowFromPartitions(store, [{ hostId: 'local', session }])
|
||||
const sync = openWorkspaceLayoutFrameSync(store)
|
||||
const first = publishedOf(session)
|
||||
stream.onFrame!({ type: 'snapshot', workspaces: [{ key: GIT_KEY, layout: first }] })
|
||||
sync.start()
|
||||
await settle()
|
||||
vi.mocked(buildWorkspaceSessionPayload).mockClear()
|
||||
const send = async (layout: PublishedWorkspaceLayout, asSnapshot = false): Promise<void> => {
|
||||
stream.onFrame!(
|
||||
asSnapshot
|
||||
? { type: 'snapshot', workspaces: [{ key: GIT_KEY, layout }] }
|
||||
: { type: 'workspace', key: GIT_KEY, layout }
|
||||
)
|
||||
await settle()
|
||||
}
|
||||
return { store, sync, first, send }
|
||||
}
|
||||
|
||||
/** What a rebuild shows for these frames: a second snapshot redraws every workspace. */
|
||||
async function rebuiltState(frames: PublishedWorkspaceLayout[]) {
|
||||
const { store, sync, send } = await boot()
|
||||
for (const frame of frames) {
|
||||
await send(frame, true)
|
||||
}
|
||||
sync.close()
|
||||
return store.getState()
|
||||
}
|
||||
|
||||
const slicesOf = (state: Record<string, unknown>) =>
|
||||
Object.fromEntries(SLICES.map((slice) => [slice, state[slice]]))
|
||||
|
||||
afterEach(() => endLayoutFramePositions())
|
||||
|
||||
describe('a frame that changes only panes’ terminal bindings', () => {
|
||||
// A respawn of a gone process: its pane is unbound, then bound again.
|
||||
it.each([
|
||||
['unbinds a pane', [[leaf(1), undefined]]],
|
||||
[
|
||||
'unbinds then binds the same id again',
|
||||
[
|
||||
[leaf(1), undefined],
|
||||
[leaf(1), `${GIT_KEY}@@aaaa0001`]
|
||||
]
|
||||
],
|
||||
['binds a new id', [[leaf(1), 'pty-fresh']]],
|
||||
['unbinds the focused pane of a split', [[leaf(2), undefined]]]
|
||||
] as const)('%s exactly as a rebuild shows it, without one', async (_, steps) => {
|
||||
const { store, sync, first, send } = await boot()
|
||||
const frames: PublishedWorkspaceLayout[] = []
|
||||
let frame = first
|
||||
for (const [leafId, ptyId] of steps) {
|
||||
frame = rebound(frame, 'tab-shell', leafId, ptyId)
|
||||
frames.push(frame)
|
||||
await send(frame)
|
||||
}
|
||||
const shown = store.getState()
|
||||
sync.close()
|
||||
|
||||
expect(buildWorkspaceSessionPayload).not.toHaveBeenCalled()
|
||||
const rebuilt = await rebuiltState(frames)
|
||||
expect(buildWorkspaceSessionPayload).toHaveBeenCalled()
|
||||
expect(slicesOf(shown)).toEqual(slicesOf(rebuilt))
|
||||
// Presence: the binding the frame names is the store's.
|
||||
const [leafId, ptyId] = steps.at(-1)!
|
||||
expect(shown.terminalLayoutsByTabId['tab-shell']!.ptyIdsByLeafId?.[leafId]).toBe(ptyId)
|
||||
})
|
||||
|
||||
it('writes nothing to the store when the store already shows those bindings', async () => {
|
||||
const { store, sync, first, send } = await boot()
|
||||
const layout = store.getState().terminalLayoutsByTabId['tab-shell']!
|
||||
store.setState({
|
||||
terminalLayoutsByTabId: {
|
||||
...store.getState().terminalLayoutsByTabId,
|
||||
'tab-shell': {
|
||||
...layout,
|
||||
ptyIdsByLeafId: { ...layout.ptyIdsByLeafId, [leaf(1)]: 'pty-fresh' }
|
||||
}
|
||||
}
|
||||
})
|
||||
const writes = vi.fn()
|
||||
const stop = store.subscribe(writes)
|
||||
|
||||
await send(rebound(first, 'tab-shell', leaf(1), 'pty-fresh'))
|
||||
|
||||
stop()
|
||||
expect(writes).not.toHaveBeenCalled()
|
||||
expect(buildWorkspaceSessionPayload).not.toHaveBeenCalled()
|
||||
sync.close()
|
||||
})
|
||||
|
||||
it('keeps a tab the window’s own pane command holds as the store has it', async () => {
|
||||
const { store, sync, first, send } = await boot()
|
||||
const before = store.getState().terminalLayoutsByTabId['tab-shell']
|
||||
const release = holdTabPaneTree('tab-shell')
|
||||
|
||||
await send(rebound(first, 'tab-shell', leaf(1), 'pty-fresh'))
|
||||
|
||||
expect(store.getState().terminalLayoutsByTabId['tab-shell']).toBe(before)
|
||||
release()
|
||||
sync.close()
|
||||
})
|
||||
|
||||
it('keeps a tab the window drew before main answered as the store has it', async () => {
|
||||
const { store, sync, first, send } = await boot()
|
||||
const before = store.getState().terminalLayoutsByTabId['tab-shell']
|
||||
const release = holdDrawnTabs([{ workspace: GIT_KEY, ids: ['tab-shell'] }])
|
||||
|
||||
await send(rebound(first, 'tab-shell', leaf(1), 'pty-fresh'))
|
||||
|
||||
expect(store.getState().terminalLayoutsByTabId['tab-shell']).toBe(before)
|
||||
release()
|
||||
sync.close()
|
||||
})
|
||||
|
||||
it('still rebuilds a frame that also changes structure', async () => {
|
||||
const { store, sync, first, send } = await boot()
|
||||
const renamed = rebound(first, 'tab-shell', leaf(1), 'pty-fresh')
|
||||
renamed.tabs.find((tab) => tab.entityId === 'tab-shell')!.customTitle = 'renamed'
|
||||
|
||||
await send(renamed)
|
||||
|
||||
expect(buildWorkspaceSessionPayload).toHaveBeenCalled()
|
||||
expect(
|
||||
store.getState().tabsByWorktree[GIT_KEY]!.find((tab) => tab.id === 'tab-shell')
|
||||
).toMatchObject({ customTitle: 'renamed' })
|
||||
sync.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
// A frame that changes only its panes' terminal bindings shows without a workspace rebuild: the
|
||||
// store's pane trees take the frame's bindings, and each tab's focused pane is resolved as the
|
||||
// rebuild resolves it. Anything only a rebuild shows exactly falls back to one.
|
||||
|
||||
import { resolvePtyBoundActiveLeafId } from '@/components/terminal-pane/terminal-layout-leaf-ids'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { layoutContainsLeafId } from '../../../shared/workspace-layout/terminal-pane-tree'
|
||||
import type { PublishedWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-published'
|
||||
import { isDrawnTabHeld } from './layout-owner-drawn-tab-holds'
|
||||
import { isTabPaneTreeHeld } from './layout-owner-pane-intents'
|
||||
|
||||
type TerminalLayouts = AppState['terminalLayoutsByTabId']
|
||||
|
||||
function sharesATerminal(layout: PublishedWorkspaceLayout): boolean {
|
||||
const ptyIds = layout.tabs.flatMap((tab) =>
|
||||
(tab.terminal?.panes ?? []).flatMap((pane) => (pane.ptyId ? [pane.ptyId] : []))
|
||||
)
|
||||
return new Set(ptyIds).size !== ptyIds.length
|
||||
}
|
||||
|
||||
/**
|
||||
* `layouts` with the frame's bindings shown, the same object when it already shows them. Null
|
||||
* when only a rebuild shows them as the rebuild would: a terminal two panes name (restore releases
|
||||
* one), a pane tree the store lacks or draws otherwise, or a tab the window's own command holds.
|
||||
*/
|
||||
export function terminalLayoutsWithFrameBindings(
|
||||
layouts: TerminalLayouts,
|
||||
workspace: string,
|
||||
frame: PublishedWorkspaceLayout
|
||||
): TerminalLayouts | null {
|
||||
if (sharesATerminal(frame)) {
|
||||
return null
|
||||
}
|
||||
let next = layouts
|
||||
for (const tab of frame.tabs) {
|
||||
const panes = tab.terminal?.panes
|
||||
if (!panes) {
|
||||
continue
|
||||
}
|
||||
const current = layouts[tab.entityId]
|
||||
const bound = current?.ptyIdsByLeafId ?? {}
|
||||
if (panes.every((pane) => bound[pane.leafId] === pane.ptyId)) {
|
||||
continue
|
||||
}
|
||||
const leafIds = new Set(panes.map((pane) => pane.leafId))
|
||||
if (
|
||||
!current?.root ||
|
||||
panes.some((pane) => !layoutContainsLeafId(current.root, pane.leafId)) ||
|
||||
Object.keys(bound).some((leafId) => !leafIds.has(leafId)) ||
|
||||
isTabPaneTreeHeld(tab.entityId) ||
|
||||
isDrawnTabHeld(workspace, tab.id) ||
|
||||
isDrawnTabHeld(workspace, tab.entityId)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const ptyIdsByLeafId = Object.fromEntries(
|
||||
panes.flatMap((pane) => (pane.ptyId ? [[pane.leafId, pane.ptyId]] : []))
|
||||
)
|
||||
const activeLeafId = resolvePtyBoundActiveLeafId({
|
||||
root: current.root,
|
||||
activeLeafId: current.activeLeafId,
|
||||
ptyIdsByLeafId
|
||||
})
|
||||
next = { ...next, [tab.entityId]: { ...current, ptyIdsByLeafId, activeLeafId } }
|
||||
}
|
||||
return next
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// What changed between two frames of one workspace: its structure, only its panes' terminal
|
||||
// bindings, or only their live titles. Each JSON is kept per frame: title frames arrive at spinner
|
||||
// rate, and a respawned terminal's binding frames one per restored pane.
|
||||
|
||||
import { shownLiveTitle } from '@/store/workspace-layout-frame-slices'
|
||||
import type { TerminalTabTitleUpdate } from '@/store/slices/terminal-tab-title-batch'
|
||||
import { stableJson } from '../../../shared/workspace-layout/workspace-layout-load-report'
|
||||
import type {
|
||||
PublishedPane,
|
||||
PublishedWorkspaceLayout
|
||||
} from '../../../shared/workspace-layout/workspace-layout-published'
|
||||
|
||||
const structureJsonByLayout = new WeakMap<PublishedWorkspaceLayout, string>()
|
||||
const unboundJsonByLayout = new WeakMap<PublishedWorkspaceLayout, string>()
|
||||
|
||||
function jsonWithoutPaneFields(
|
||||
layout: PublishedWorkspaceLayout,
|
||||
strip: (pane: PublishedPane) => object
|
||||
): string {
|
||||
return stableJson({
|
||||
...layout,
|
||||
tabs: layout.tabs.map((tab) =>
|
||||
tab.terminal
|
||||
? { ...tab, terminal: { ...tab.terminal, panes: tab.terminal.panes.map(strip) } }
|
||||
: tab
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function cached(
|
||||
cache: WeakMap<PublishedWorkspaceLayout, string>,
|
||||
layout: PublishedWorkspaceLayout,
|
||||
strip: (pane: PublishedPane) => object
|
||||
): string {
|
||||
let json = cache.get(layout)
|
||||
if (json === undefined) {
|
||||
json = jsonWithoutPaneFields(layout, strip)
|
||||
cache.set(layout, json)
|
||||
}
|
||||
return json
|
||||
}
|
||||
|
||||
/** A frame's JSON without live titles. */
|
||||
export function structureJson(layout: PublishedWorkspaceLayout): string {
|
||||
return cached(structureJsonByLayout, layout, ({ liveTitle: _liveTitle, ...pane }) => pane)
|
||||
}
|
||||
|
||||
/** A frame's JSON without live titles or terminal bindings: a pane's binding is its status. */
|
||||
export function unboundStructureJson(layout: PublishedWorkspaceLayout): string {
|
||||
return cached(
|
||||
unboundJsonByLayout,
|
||||
layout,
|
||||
({ liveTitle: _liveTitle, ptyId: _ptyId, ptyGeneration: _ptyGeneration, ...pane }) => pane
|
||||
)
|
||||
}
|
||||
|
||||
/** Title changes between two frames of one workspace, for the pane each tab shows. */
|
||||
export function liveTitleUpdates(
|
||||
shown: PublishedWorkspaceLayout,
|
||||
next: PublishedWorkspaceLayout,
|
||||
focusedLeafId: (tabId: string) => string | null | undefined
|
||||
): TerminalTabTitleUpdate[] {
|
||||
const shownById = new Map(shown.tabs.map((tab) => [tab.id, tab]))
|
||||
const updates: TerminalTabTitleUpdate[] = []
|
||||
for (const tab of next.tabs) {
|
||||
const shownTab = shownById.get(tab.id)
|
||||
if (!tab.terminal || !shownTab) {
|
||||
continue
|
||||
}
|
||||
const focused = focusedLeafId(tab.entityId)
|
||||
const title = shownLiveTitle(tab, focused)
|
||||
if (title !== undefined && title !== shownLiveTitle(shownTab, focused)) {
|
||||
updates.push({ tabId: tab.entityId, title })
|
||||
}
|
||||
}
|
||||
return updates
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as AgentStatusModule from '@/lib/agent-status'
|
||||
import type * as WorkspaceSessionModule from '@/lib/workspace-session'
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
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 {
|
||||
diffPublishedLayouts,
|
||||
indexPublishedLayout
|
||||
} from '../../../shared/workspace-layout/workspace-layout-records'
|
||||
import type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames'
|
||||
import {
|
||||
addWorkspace,
|
||||
emptySession,
|
||||
GIT_KEY
|
||||
} from '../../../shared/workspace-layout/workspace-layout-session.test-fixture'
|
||||
import { buildWorkspaceSessionPayload } from '@/lib/workspace-session'
|
||||
import { bootWindowFromPartitions } from '../app-shell/startup-window-view.test-fixture'
|
||||
import { createStoreSessionMockApi } from '../store/slices/store-session-test-harness'
|
||||
import { createTestStore } from '../store/slices/store-test-helpers'
|
||||
import { endLayoutFramePositions } from './layout-frame-position'
|
||||
import { openWorkspaceLayoutFrameSync } from './workspace-layout-frame-sync'
|
||||
|
||||
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) }
|
||||
})
|
||||
// The rebuild reads the window's session back first: a call means a workspace rebuild ran.
|
||||
vi.mock('@/lib/workspace-session', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof WorkspaceSessionModule>()
|
||||
return { ...actual, buildWorkspaceSessionPayload: vi.fn(actual.buildWorkspaceSessionPayload) }
|
||||
})
|
||||
|
||||
const stream = vi.hoisted(
|
||||
(): { onFrame: ((frame: WorkspaceLayoutChangeFrame) => void) | null } => ({ onFrame: null })
|
||||
)
|
||||
vi.mock('./runtime-layout-client', () => ({
|
||||
subscribeRuntimeLayout: (
|
||||
_target: unknown,
|
||||
_params: unknown,
|
||||
onFrame: (frame: WorkspaceLayoutChangeFrame) => void
|
||||
) => {
|
||||
stream.onFrame = onFrame
|
||||
return { close: () => {} }
|
||||
}
|
||||
}))
|
||||
|
||||
createStoreSessionMockApi()
|
||||
|
||||
const TABS = 100
|
||||
const settle = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
const leafOf = (index: number) => `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`
|
||||
|
||||
const terminalSpec = (index: number): { id: string; leaves: [string, string][] } => ({
|
||||
id: `tab-${index}`,
|
||||
leaves: [[leafOf(index), `pty-${index}`]]
|
||||
})
|
||||
|
||||
function hundredTabs(): WorkspaceSessionState {
|
||||
return addWorkspace(emptySession(), GIT_KEY, [
|
||||
{
|
||||
id: 'group-a',
|
||||
tabs: Array.from({ length: TABS }, (_, index) => terminalSpec(index))
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
function publishedOf(session: WorkspaceSessionState): PublishedWorkspaceLayout {
|
||||
const { layout } = loadWorkspaceLayoutWithoutReport('local', session, nameBasedLoadContext())
|
||||
return publishWorkspaceLayout(layout.workspaces[GIT_KEY]!, 'local')
|
||||
}
|
||||
|
||||
/** Main's one frame for an operation that turned `before` into `after`. */
|
||||
function recordsFrame(
|
||||
before: PublishedWorkspaceLayout,
|
||||
after: PublishedWorkspaceLayout
|
||||
): WorkspaceLayoutChangeFrame {
|
||||
const change = diffPublishedLayouts(
|
||||
GIT_KEY,
|
||||
indexPublishedLayout(before),
|
||||
indexPublishedLayout(after),
|
||||
after
|
||||
)
|
||||
return { type: 'records', epoch: 'epoch-1', seq: 1, workspaces: change ? [change] : [] }
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
const store = createTestStore()
|
||||
const session = hundredTabs()
|
||||
await bootWindowFromPartitions(store, [{ hostId: 'local', session }])
|
||||
const sync = openWorkspaceLayoutFrameSync(store)
|
||||
const first = publishedOf(session)
|
||||
stream.onFrame!({ type: 'snapshot', workspaces: [{ key: GIT_KEY, layout: first }], seq: 0 })
|
||||
sync.start()
|
||||
await settle()
|
||||
vi.mocked(buildWorkspaceSessionPayload).mockClear()
|
||||
const writes = vi.fn()
|
||||
const stopWrites = store.subscribe(writes)
|
||||
const send = async (frame: WorkspaceLayoutChangeFrame): Promise<void> => {
|
||||
stream.onFrame!(frame)
|
||||
await settle()
|
||||
}
|
||||
return { store, first, writes, send, close: () => (stopWrites(), sync.close()) }
|
||||
}
|
||||
|
||||
afterEach(() => endLayoutFramePositions())
|
||||
|
||||
describe('a records frame in the window', () => {
|
||||
it('shows 100 generated titles from one frame in one store update', async () => {
|
||||
const { store, first, writes, send, close } = await boot()
|
||||
const titled = structuredClone(first)
|
||||
titled.tabs.forEach((tab, index) => {
|
||||
tab.generatedTitle = `Title ${index}`
|
||||
})
|
||||
|
||||
await send(recordsFrame(first, titled))
|
||||
|
||||
expect(writes).toHaveBeenCalledTimes(1)
|
||||
expect(store.getState().tabsByWorktree[GIT_KEY]!.map((tab) => tab.generatedTitle)).toEqual(
|
||||
titled.tabs.map((tab) => tab.generatedTitle)
|
||||
)
|
||||
close()
|
||||
})
|
||||
|
||||
it('shows a binding record without a rebuild', async () => {
|
||||
const { store, first, send, close } = await boot()
|
||||
const rebound = structuredClone(first)
|
||||
rebound.tabs[7]!.terminal!.panes[0]!.ptyId = 'pty-fresh'
|
||||
rebound.tabs[7]!.terminal!.panes[0]!.ptyGeneration = 2
|
||||
|
||||
await send(recordsFrame(first, rebound))
|
||||
|
||||
expect(buildWorkspaceSessionPayload).not.toHaveBeenCalled()
|
||||
expect(store.getState().terminalLayoutsByTabId['tab-7']!.ptyIdsByLeafId).toEqual({
|
||||
[leafOf(7)]: 'pty-fresh'
|
||||
})
|
||||
close()
|
||||
})
|
||||
|
||||
it('writes nothing for a respawn under the same id: only its generation rose', async () => {
|
||||
const { first, writes, send, close } = await boot()
|
||||
const respawned = structuredClone(first)
|
||||
respawned.tabs[3]!.terminal!.panes[0]!.ptyGeneration = 2
|
||||
|
||||
await send(recordsFrame(first, respawned))
|
||||
|
||||
expect(writes).not.toHaveBeenCalled()
|
||||
expect(buildWorkspaceSessionPayload).not.toHaveBeenCalled()
|
||||
close()
|
||||
})
|
||||
})
|
||||
@@ -10,7 +10,7 @@ import { useAppStore } from '@/store'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { applyLayoutFrame, EMPTY_WORKSPACE_LAYOUT_CACHE } from '@/store/workspace-layout-cache'
|
||||
import type { WorkspaceLayoutCache } from '@/store/workspace-layout-cache'
|
||||
import { buildLayoutFrameSlices, shownLiveTitle } from '@/store/workspace-layout-frame-slices'
|
||||
import { buildLayoutFrameSlices } from '@/store/workspace-layout-frame-slices'
|
||||
import {
|
||||
buildLayoutFrameStorePatch,
|
||||
terminalLayoutsPatch
|
||||
@@ -28,6 +28,12 @@ import { loadWorkspaceLayoutWithoutReport } from '../../../shared/workspace-layo
|
||||
import { stableJson } from '../../../shared/workspace-layout/workspace-layout-load-report'
|
||||
import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids'
|
||||
import type { PublishedWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-published'
|
||||
import {
|
||||
liveTitleUpdates,
|
||||
structureJson,
|
||||
unboundStructureJson
|
||||
} from './workspace-layout-frame-changes'
|
||||
import { terminalLayoutsWithFrameBindings } from './workspace-layout-frame-bindings'
|
||||
import {
|
||||
beginLayoutFramePositions,
|
||||
endLayoutFramePositions,
|
||||
@@ -64,57 +70,6 @@ export type WorkspaceLayoutFrameSync = LayoutFrameTerminalQueries & {
|
||||
/** A bucket, not a host: the view read-back routes the workspaces a rebuild skips here. */
|
||||
const OTHER_WORKSPACES: ExecutionHostId = 'runtime:frame-sync-other-workspaces'
|
||||
|
||||
const structureJsonByLayout = new WeakMap<PublishedWorkspaceLayout, string>()
|
||||
|
||||
/** A frame's JSON without live titles, kept per frame: title frames arrive at spinner rate. */
|
||||
function structureJson(layout: PublishedWorkspaceLayout): string {
|
||||
let json = structureJsonByLayout.get(layout)
|
||||
if (json === undefined) {
|
||||
json = withoutLiveTitles(layout)
|
||||
structureJsonByLayout.set(layout, json)
|
||||
}
|
||||
return json
|
||||
}
|
||||
|
||||
function withoutLiveTitles(layout: PublishedWorkspaceLayout): string {
|
||||
return stableJson({
|
||||
...layout,
|
||||
tabs: layout.tabs.map((tab) =>
|
||||
tab.terminal
|
||||
? {
|
||||
...tab,
|
||||
terminal: {
|
||||
...tab.terminal,
|
||||
panes: tab.terminal.panes.map(({ liveTitle: _liveTitle, ...pane }) => pane)
|
||||
}
|
||||
}
|
||||
: tab
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/** Title changes between two frames of one workspace, for the pane each tab shows. */
|
||||
function liveTitleUpdates(
|
||||
shown: PublishedWorkspaceLayout,
|
||||
next: PublishedWorkspaceLayout,
|
||||
focusedLeafId: (tabId: string) => string | null | undefined
|
||||
): TerminalTabTitleUpdate[] {
|
||||
const shownById = new Map(shown.tabs.map((tab) => [tab.id, tab]))
|
||||
const updates: TerminalTabTitleUpdate[] = []
|
||||
for (const tab of next.tabs) {
|
||||
const shownTab = shownById.get(tab.id)
|
||||
if (!tab.terminal || !shownTab) {
|
||||
continue
|
||||
}
|
||||
const focused = focusedLeafId(tab.entityId)
|
||||
const title = shownLiveTitle(tab, focused)
|
||||
if (title !== undefined && title !== shownLiveTitle(shownTab, focused)) {
|
||||
updates.push({ tabId: tab.entityId, title })
|
||||
}
|
||||
}
|
||||
return updates
|
||||
}
|
||||
|
||||
export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLayoutFrameSync {
|
||||
let cache: WorkspaceLayoutCache = EMPTY_WORKSPACE_LAYOUT_CACHE
|
||||
/** Workspace key → the frame the store shows. */
|
||||
@@ -188,6 +143,24 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa
|
||||
}
|
||||
}
|
||||
|
||||
// Only panes' bindings changed: the pane trees take them, unless only a rebuild shows them so.
|
||||
const showFrameBindings = (keys: string[]): void => {
|
||||
const state = store.getState()
|
||||
let layouts = state.terminalLayoutsByTabId
|
||||
const unshown = keys.filter((key) => {
|
||||
const next = terminalLayoutsWithFrameBindings(layouts, key, cache[key]!)
|
||||
layouts = next ?? layouts
|
||||
return next === null
|
||||
})
|
||||
const patch = terminalLayoutsPatch(state, layouts)
|
||||
if (Object.keys(patch).length > 0) {
|
||||
store.setState(patch)
|
||||
}
|
||||
if (unshown.length > 0) {
|
||||
rebuild(store.getState(), unshown)
|
||||
}
|
||||
}
|
||||
|
||||
const flush = (): void => {
|
||||
flushQueued = false
|
||||
if (!live) {
|
||||
@@ -197,6 +170,7 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa
|
||||
const drawn = framesDrawWorkspace(state)
|
||||
const nextShown: Record<string, PublishedWorkspaceLayout> = {}
|
||||
const structural: string[] = []
|
||||
const rebound: string[] = []
|
||||
const titleUpdates: TerminalTabTitleUpdate[] = []
|
||||
for (const [key, layout] of Object.entries(cache)) {
|
||||
const previous = shown[key]
|
||||
@@ -208,7 +182,11 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa
|
||||
}
|
||||
// Against the store, not `shown`: a held frame leaves `shown` behind the pane trees drawn since.
|
||||
paneTreeKeys.add(key)
|
||||
if (previous && structureJson(previous) === structureJson(layout)) {
|
||||
const sameStructure = previous && structureJson(previous) === structureJson(layout)
|
||||
if (
|
||||
sameStructure ||
|
||||
(previous && unboundStructureJson(previous) === unboundStructureJson(layout))
|
||||
) {
|
||||
titleUpdates.push(
|
||||
...liveTitleUpdates(
|
||||
previous,
|
||||
@@ -216,17 +194,22 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa
|
||||
(tabId) => state.terminalLayoutsByTabId[tabId]?.activeLeafId
|
||||
)
|
||||
)
|
||||
nextShown[key] = layout
|
||||
if (!sameStructure) {
|
||||
rebound.push(key)
|
||||
}
|
||||
} else {
|
||||
structural.push(key)
|
||||
nextShown[key] = layout
|
||||
}
|
||||
nextShown[key] = layout
|
||||
}
|
||||
// A workspace main stopped publishing keeps what the window shows; main has nothing to say.
|
||||
shown = nextShown
|
||||
if (structural.length > 0) {
|
||||
rebuild(state, structural)
|
||||
}
|
||||
if (rebound.length > 0) {
|
||||
showFrameBindings(rebound)
|
||||
}
|
||||
if (titleUpdates.length > 0) {
|
||||
store.getState().updateTabTitles(titleUpdates)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { stableJson } from '../../../shared/workspace-layout/workspace-layout-load-report'
|
||||
import type { PublishedWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-published'
|
||||
import { applyWorkspaceLayoutRecordChange } from '../../../shared/workspace-layout/workspace-layout-records'
|
||||
import type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames'
|
||||
|
||||
export type WorkspaceLayoutCache = Readonly<Record<string, PublishedWorkspaceLayout>>
|
||||
@@ -11,8 +12,10 @@ export type WorkspaceLayoutCache = Readonly<Record<string, PublishedWorkspaceLay
|
||||
export const EMPTY_WORKSPACE_LAYOUT_CACHE: WorkspaceLayoutCache = {}
|
||||
|
||||
/**
|
||||
* The cache's only writer (design 3.3's `applyFromRuntime`): replaces from the runtime's frame;
|
||||
* nothing edits, merges or saves it. Returns the same cache when nothing changed.
|
||||
* The cache's only writer (design 3.3's `applyFromRuntime`): replaces from the runtime's frame,
|
||||
* or applies its changed records to the workspace they name; nothing else edits, merges or saves
|
||||
* it. Returns the same cache when nothing changed. Throws on records that do not fit the cache:
|
||||
* the stream must then be fetched again.
|
||||
*/
|
||||
export function applyLayoutFrame(
|
||||
cache: WorkspaceLayoutCache,
|
||||
@@ -29,6 +32,18 @@ export function applyLayoutFrame(
|
||||
const { [frame.key]: _removed, ...rest } = cache
|
||||
return rest
|
||||
}
|
||||
case 'records': {
|
||||
const next: Record<string, PublishedWorkspaceLayout> = { ...cache }
|
||||
for (const change of frame.workspaces) {
|
||||
const layout = applyWorkspaceLayoutRecordChange(next[change.key], change)
|
||||
if (layout) {
|
||||
next[change.key] = layout
|
||||
} else {
|
||||
delete next[change.key]
|
||||
}
|
||||
}
|
||||
return frame.workspaces.length > 0 ? next : cache
|
||||
}
|
||||
case 'snapshot': {
|
||||
// Keeps each equal entry, so a reconnect with unchanged data re-renders nothing.
|
||||
const next: Record<string, PublishedWorkspaceLayout> = {}
|
||||
|
||||
@@ -68,7 +68,8 @@ type PartSources = {
|
||||
| Prefixed<'file', keyof LayoutEditorFile>
|
||||
| Prefixed<'browserTab', keyof LayoutBrowserTab>
|
||||
// `$key`: the map key the entry is stored under.
|
||||
| Prefixed<'leaf', Exclude<keyof LayoutLeaf, 'sleeping'> | '$key'>
|
||||
// `ptyGeneration` is in memory only: a load starts every pane's count afresh.
|
||||
| Prefixed<'leaf', Exclude<keyof LayoutLeaf, 'sleeping' | 'ptyGeneration'> | '$key'>
|
||||
| Prefixed<'leaf.sleeping', keyof LayoutSleepingRecord>
|
||||
| Prefixed<'closedTab', keyof LayoutClosedTab>
|
||||
desktopView:
|
||||
|
||||
@@ -135,6 +135,11 @@ export function loadLeaves(
|
||||
if (leafId !== storedId) {
|
||||
// A pane given a new id starts unbound: its terminal was started for the old one.
|
||||
delete leaf.ptyId
|
||||
} else {
|
||||
const generation = context.ptyGenerationOf?.(leafId)
|
||||
if (generation !== undefined) {
|
||||
leaf.ptyGeneration = generation
|
||||
}
|
||||
}
|
||||
if (Object.keys(leaf).length > 0) {
|
||||
workspace.leaves = { ...workspace.leaves, [leafId]: leaf }
|
||||
|
||||
@@ -17,6 +17,8 @@ export type WorkspaceLayoutLoadContext = {
|
||||
mintId: (seed: string) => string
|
||||
/** Mints pane ids (UUIDs) for a repeated pane id or a legacy row's one pane; `seed` as above. */
|
||||
mintLeafId: (seed: string) => string
|
||||
/** A live pane's binding generation (main's, in memory); absent when the context has none. */
|
||||
ptyGenerationOf?: (leafId: string) => number | undefined
|
||||
}
|
||||
|
||||
/** A stored session partition, split into the layout and what is kept beside it. */
|
||||
|
||||
@@ -79,6 +79,8 @@ export type LayoutClosedTab = Omit<ClosedTerminalTabTombstone, 'worktreeId'>
|
||||
export type LayoutLeaf = {
|
||||
/** The terminal it shows; absent while unbound. */
|
||||
ptyId?: string
|
||||
/** Rises on each pty bind, retire and rollback of this pane in this process. In memory only. */
|
||||
ptyGeneration?: number
|
||||
incarnationId?: string
|
||||
/** User pane title. */
|
||||
title?: string
|
||||
|
||||
@@ -24,6 +24,8 @@ export type PublishedPane = {
|
||||
leafId: string
|
||||
/** The terminal the pane shows; absent while unbound. */
|
||||
ptyId?: string
|
||||
/** Rises on each pty bind, retire and rollback of the pane in this process (a same-id respawn too). */
|
||||
ptyGeneration?: number
|
||||
title?: string
|
||||
/** The title the pane's terminal shows now. Live state, never saved; the tab's title stays each
|
||||
* view's choice, since it follows that view's focused pane. */
|
||||
@@ -113,7 +115,10 @@ function publishTab(
|
||||
if (tab.kind === 'terminal') {
|
||||
const panes = collectLayoutLeafIdsInOrder(tab.panes.root).map((leafId): PublishedPane => {
|
||||
const leaf = workspace.leaves?.[leafId]
|
||||
return { leafId, ...(leaf ? pickStoredFields(leaf, ['ptyId', 'title']) : {}) }
|
||||
return {
|
||||
leafId,
|
||||
...(leaf ? pickStoredFields(leaf, ['ptyId', 'ptyGeneration', 'title']) : {})
|
||||
}
|
||||
})
|
||||
published.terminal = {
|
||||
root: tab.panes.root,
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../execution-host'
|
||||
import { loadWorkspaceLayoutWithoutReport } from './workspace-layout-load'
|
||||
import { stableJson } from './workspace-layout-load-report'
|
||||
import { nameBasedLoadContext } from './workspace-layout-minted-ids'
|
||||
import { localDesktopSession } from './workspace-layout-profile.test-fixture'
|
||||
import { publishWorkspaceLayout, type PublishedWorkspaceLayout } from './workspace-layout-published'
|
||||
import {
|
||||
applyWorkspaceLayoutRecordChange,
|
||||
diffPublishedLayouts,
|
||||
indexPublishedLayout,
|
||||
joinPublishedLayout,
|
||||
splitPublishedLayout
|
||||
} from './workspace-layout-records'
|
||||
import { GIT_KEY, leaf } from './workspace-layout-session.test-fixture'
|
||||
|
||||
function published(): PublishedWorkspaceLayout {
|
||||
const { layout } = loadWorkspaceLayoutWithoutReport(
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
localDesktopSession(),
|
||||
nameBasedLoadContext()
|
||||
)
|
||||
return publishWorkspaceLayout(layout.workspaces[GIT_KEY]!, LOCAL_EXECUTION_HOST_ID)
|
||||
}
|
||||
|
||||
const diff = (before: PublishedWorkspaceLayout, after: PublishedWorkspaceLayout) =>
|
||||
diffPublishedLayouts(GIT_KEY, indexPublishedLayout(before), indexPublishedLayout(after), after)
|
||||
|
||||
/** `before` with one edit: the frame for it carries what changed and applies back to `after`. */
|
||||
function edited(edit: (layout: PublishedWorkspaceLayout) => void) {
|
||||
const before = published()
|
||||
const after = structuredClone(before)
|
||||
edit(after)
|
||||
const change = diff(before, after)!
|
||||
const applied = applyWorkspaceLayoutRecordChange(before, change)
|
||||
return { change, before, after, applied }
|
||||
}
|
||||
|
||||
const shellPane = (layout: PublishedWorkspaceLayout, leafId: string) =>
|
||||
layout.tabs
|
||||
.find((tab) => tab.id === 'tab-shell')!
|
||||
.terminal!.panes.find((pane) => pane.leafId === leafId)!
|
||||
|
||||
describe('a published layout as records', () => {
|
||||
it('splits and joins back to the same layout, field for field', () => {
|
||||
const layout = published()
|
||||
expect(stableJson(joinPublishedLayout(splitPublishedLayout(layout)))).toBe(stableJson(layout))
|
||||
})
|
||||
|
||||
it('keeps a pane’s binding out of its tab’s tree', () => {
|
||||
const records = splitPublishedLayout(published())
|
||||
const tree = records.paneTrees.find((candidate) => candidate.tabId === 'tab-shell')!
|
||||
expect(tree.panes.every((pane) => !('ptyId' in pane))).toBe(true)
|
||||
expect(records.bindings).toContainEqual({ leafId: leaf(1), ptyId: `${GIT_KEY}@@aaaa0001` })
|
||||
})
|
||||
|
||||
it('sends nothing when nothing changed', () => {
|
||||
expect(diff(published(), published())).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'a respawn under the same id: one binding record',
|
||||
(layout: PublishedWorkspaceLayout) => {
|
||||
shellPane(layout, leaf(1)).ptyGeneration = 2
|
||||
},
|
||||
{
|
||||
bindings: { set: [{ leafId: leaf(1), ptyId: `${GIT_KEY}@@aaaa0001`, ptyGeneration: 2 }] }
|
||||
}
|
||||
],
|
||||
[
|
||||
'a pane unbound: its binding record removed',
|
||||
(layout: PublishedWorkspaceLayout) => {
|
||||
delete shellPane(layout, leaf(1)).ptyId
|
||||
},
|
||||
{ bindings: { removed: [leaf(1)] } }
|
||||
],
|
||||
[
|
||||
'a generated title: one tab row',
|
||||
(layout: PublishedWorkspaceLayout) => {
|
||||
layout.tabs[0]!.generatedTitle = 'Fix the build'
|
||||
},
|
||||
{
|
||||
tabs: {
|
||||
set: [expect.objectContaining({ id: 'tab-shell', generatedTitle: 'Fix the build' })]
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'a tab moved within its group: the workspace fields and the tab order',
|
||||
(layout: PublishedWorkspaceLayout) => {
|
||||
layout.groups[0]!.tabIds = layout.groups[0]!.tabIds.toReversed()
|
||||
layout.tabs = [...layout.tabs.slice(0, 3).toReversed(), ...layout.tabs.slice(3)]
|
||||
},
|
||||
{
|
||||
workspace: expect.objectContaining({ groups: expect.any(Array) }),
|
||||
tabs: { order: expect.any(Array) }
|
||||
}
|
||||
]
|
||||
])('%s', (_case, edit, expected) => {
|
||||
const { change, after, applied } = edited(edit)
|
||||
expect(change).toEqual({ key: GIT_KEY, change: 'records', ...expected })
|
||||
expect(stableJson(applied)).toBe(stableJson(after))
|
||||
})
|
||||
|
||||
it('applies a tab closed, with its tree and bindings, back to the layout without it', () => {
|
||||
const { change, after, applied } = edited((layout) => {
|
||||
layout.tabs = layout.tabs.filter((tab) => tab.id !== 'tab-shell')
|
||||
layout.groups[0]!.tabIds = layout.groups[0]!.tabIds.filter((id) => id !== 'tab-shell')
|
||||
})
|
||||
expect(change).toMatchObject({
|
||||
tabs: { removed: ['tab-shell'] },
|
||||
paneTrees: { removed: ['tab-shell'] },
|
||||
bindings: { removed: expect.arrayContaining([leaf(1)]) }
|
||||
})
|
||||
expect(stableJson(applied)).toBe(stableJson(after))
|
||||
})
|
||||
|
||||
it('sends a layout whose records repeat a key whole', () => {
|
||||
const before = published()
|
||||
const after = structuredClone(before)
|
||||
after.browserTabs = [...after.browserTabs, { ...after.browserTabs[0]!, label: 'other' }]
|
||||
expect(indexPublishedLayout(after)).toBeNull()
|
||||
expect(diff(before, after)).toEqual({ key: GIT_KEY, change: 'layout', layout: after })
|
||||
})
|
||||
|
||||
it('refuses a change that does not fit what the reader holds', () => {
|
||||
const { change } = edited((layout) => {
|
||||
layout.tabs[0]!.customTitle = 'renamed'
|
||||
})
|
||||
expect(() => applyWorkspaceLayoutRecordChange(undefined, change)).toThrow('unknown workspace')
|
||||
expect(() =>
|
||||
applyWorkspaceLayoutRecordChange(published(), {
|
||||
key: GIT_KEY,
|
||||
change: 'records',
|
||||
tabs: { order: ['tab-shell', 'missing'] }
|
||||
})
|
||||
).toThrow(/missing/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,310 @@
|
||||
// A published workspace layout as the records main keys by its own ids: the workspace's groups,
|
||||
// each tab's row, each terminal tab's pane tree, each pane's terminal binding, each editor file and
|
||||
// browser tab. A change frame carries only the records an operation changed (design E1); splitting
|
||||
// and joining keep every field, so a field added to a published type rides its record unchanged.
|
||||
|
||||
import { editorFileKey } from './workspace-layout-editor-tab-file'
|
||||
import { stableJson } from './workspace-layout-load-report'
|
||||
import type {
|
||||
PublishedBrowserTab,
|
||||
PublishedEditorFile,
|
||||
PublishedPane,
|
||||
PublishedTab,
|
||||
PublishedTerminal,
|
||||
PublishedWorkspaceLayout
|
||||
} from './workspace-layout-published'
|
||||
|
||||
/** The workspace's own fields: its groups (the one tab order) and group tree. */
|
||||
export type PublishedWorkspaceFields = Omit<
|
||||
PublishedWorkspaceLayout,
|
||||
'tabs' | 'editorFiles' | 'browserTabs'
|
||||
>
|
||||
|
||||
/** A tab's row: all but its panes. A terminal tab keeps its creation fields here. */
|
||||
export type PublishedTabRow = Omit<PublishedTab, 'terminal'> & {
|
||||
terminal?: Omit<PublishedTerminal, 'root' | 'panes' | 'chatLeafId'>
|
||||
}
|
||||
|
||||
/** A pane as its tab's tree holds it: all but its terminal binding. */
|
||||
export type PublishedTreePane = Omit<PublishedPane, 'ptyId' | 'ptyGeneration'>
|
||||
|
||||
/** A terminal tab's pane tree, keyed by the tab's id. */
|
||||
export type PublishedPaneTree = Pick<PublishedTerminal, 'root' | 'chatLeafId'> & {
|
||||
tabId: string
|
||||
/** In tree order. */
|
||||
panes: PublishedTreePane[]
|
||||
}
|
||||
|
||||
/** A pane's terminal binding, apart from its tree so a respawn changes only this record. */
|
||||
export type PublishedPaneBinding = Pick<PublishedPane, 'leafId' | 'ptyId' | 'ptyGeneration'>
|
||||
|
||||
type RecordKinds = {
|
||||
tabs: PublishedTabRow
|
||||
paneTrees: PublishedPaneTree
|
||||
bindings: PublishedPaneBinding
|
||||
editorFiles: PublishedEditorFile
|
||||
browserTabs: PublishedBrowserTab
|
||||
}
|
||||
|
||||
type RecordKind = keyof RecordKinds
|
||||
|
||||
export type PublishedLayoutRecords = { workspace: PublishedWorkspaceFields } & {
|
||||
[Kind in RecordKind]: RecordKinds[Kind][]
|
||||
}
|
||||
|
||||
/** One kind's changes: `set` adds or replaces by key; `order` is every key in order, sent when it changed. */
|
||||
export type LayoutRecordChanges<T> = { set?: T[]; removed?: string[]; order?: string[] }
|
||||
|
||||
export type WorkspaceLayoutRecordChanges = { workspace?: PublishedWorkspaceFields } & {
|
||||
[Kind in RecordKind]?: LayoutRecordChanges<RecordKinds[Kind]>
|
||||
}
|
||||
|
||||
export type WorkspaceLayoutRecordChange =
|
||||
| { key: string; change: 'removed' }
|
||||
/** A workspace new to the stream, or one whose records repeat a key, goes whole. */
|
||||
| { key: string; change: 'layout'; layout: PublishedWorkspaceLayout }
|
||||
| ({ key: string; change: 'records' } & WorkspaceLayoutRecordChanges)
|
||||
|
||||
const RECORD_KEYS: { [Kind in RecordKind]: (record: RecordKinds[Kind]) => string } = {
|
||||
tabs: (row) => row.id,
|
||||
paneTrees: (tree) => tree.tabId,
|
||||
bindings: (binding) => binding.leafId,
|
||||
editorFiles: (file) => editorFileKey(file),
|
||||
browserTabs: (tab) => tab.id
|
||||
}
|
||||
|
||||
export function splitPublishedLayout(layout: PublishedWorkspaceLayout): PublishedLayoutRecords {
|
||||
const { tabs, editorFiles, browserTabs, ...workspace } = layout
|
||||
const rows: PublishedTabRow[] = []
|
||||
const paneTrees: PublishedPaneTree[] = []
|
||||
const bindings: PublishedPaneBinding[] = []
|
||||
for (const tab of tabs) {
|
||||
const { terminal, ...row } = tab
|
||||
if (!terminal) {
|
||||
rows.push(row)
|
||||
continue
|
||||
}
|
||||
const { root, panes, chatLeafId, ...creation } = terminal
|
||||
rows.push({ ...row, terminal: creation })
|
||||
paneTrees.push({
|
||||
tabId: tab.id,
|
||||
root,
|
||||
...(chatLeafId !== undefined ? { chatLeafId } : {}),
|
||||
panes: panes.map(({ ptyId, ptyGeneration, ...pane }) => {
|
||||
if (ptyId !== undefined || ptyGeneration !== undefined) {
|
||||
bindings.push({
|
||||
leafId: pane.leafId,
|
||||
...(ptyId !== undefined ? { ptyId } : {}),
|
||||
...(ptyGeneration !== undefined ? { ptyGeneration } : {})
|
||||
})
|
||||
}
|
||||
return pane
|
||||
})
|
||||
})
|
||||
}
|
||||
return { workspace, tabs: rows, paneTrees, bindings, editorFiles, browserTabs }
|
||||
}
|
||||
|
||||
/** Throws when a terminal tab's row has no pane tree: the records are not one layout. */
|
||||
export function joinPublishedLayout(records: PublishedLayoutRecords): PublishedWorkspaceLayout {
|
||||
const trees = new Map(records.paneTrees.map((tree) => [tree.tabId, tree]))
|
||||
const bindings = new Map(records.bindings.map((binding) => [binding.leafId, binding]))
|
||||
const tabs = records.tabs.map((row): PublishedTab => {
|
||||
const { terminal, ...rest } = row
|
||||
if (!terminal) {
|
||||
return rest
|
||||
}
|
||||
const tree = trees.get(row.id)
|
||||
if (!tree) {
|
||||
throw new Error(`layout records: terminal tab ${row.id} has no pane tree`)
|
||||
}
|
||||
const { tabId: _tabId, panes, ...treeFields } = tree
|
||||
return {
|
||||
...rest,
|
||||
terminal: {
|
||||
...terminal,
|
||||
...treeFields,
|
||||
panes: panes.map((pane) => {
|
||||
const binding = bindings.get(pane.leafId)
|
||||
if (!binding) {
|
||||
return pane
|
||||
}
|
||||
const { leafId: _leafId, ...bound } = binding
|
||||
return { ...pane, ...bound }
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
return {
|
||||
...records.workspace,
|
||||
tabs,
|
||||
editorFiles: records.editorFiles,
|
||||
browserTabs: records.browserTabs
|
||||
}
|
||||
}
|
||||
|
||||
type IndexedKind<T> = { order: string[]; byKey: Map<string, { record: T; json: string }> }
|
||||
|
||||
/** A layout's records with each one's JSON, so a later layout diffs record by record. */
|
||||
export type IndexedLayoutRecords = {
|
||||
workspaceJson: string
|
||||
workspace: PublishedWorkspaceFields
|
||||
} & {
|
||||
[Kind in RecordKind]: IndexedKind<RecordKinds[Kind]>
|
||||
}
|
||||
|
||||
function indexKind<T>(records: T[], keyOf: (record: T) => string): IndexedKind<T> | null {
|
||||
const order: string[] = []
|
||||
const byKey = new Map<string, { record: T; json: string }>()
|
||||
for (const record of records) {
|
||||
const key = keyOf(record)
|
||||
const json = stableJson(record)
|
||||
const seen = byKey.get(key)
|
||||
// A tab in two groups repeats the same row; two records under one key cannot be keyed.
|
||||
if (seen && seen.json !== json) {
|
||||
return null
|
||||
}
|
||||
byKey.set(key, { record, json })
|
||||
order.push(key)
|
||||
}
|
||||
return { order, byKey }
|
||||
}
|
||||
|
||||
/** Null when two different records share a key: that layout only goes whole. */
|
||||
export function indexPublishedLayout(
|
||||
layout: PublishedWorkspaceLayout
|
||||
): IndexedLayoutRecords | null {
|
||||
const records = splitPublishedLayout(layout)
|
||||
const tabs = indexKind(records.tabs, RECORD_KEYS.tabs)
|
||||
const paneTrees = indexKind(records.paneTrees, RECORD_KEYS.paneTrees)
|
||||
const bindings = indexKind(records.bindings, RECORD_KEYS.bindings)
|
||||
const editorFiles = indexKind(records.editorFiles, RECORD_KEYS.editorFiles)
|
||||
const browserTabs = indexKind(records.browserTabs, RECORD_KEYS.browserTabs)
|
||||
if (!tabs || !paneTrees || !bindings || !editorFiles || !browserTabs) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
workspaceJson: stableJson(records.workspace),
|
||||
workspace: records.workspace,
|
||||
tabs,
|
||||
paneTrees,
|
||||
bindings,
|
||||
editorFiles,
|
||||
browserTabs
|
||||
}
|
||||
}
|
||||
|
||||
const sameOrder = (a: string[], b: string[]) =>
|
||||
a.length === b.length && a.every((key, index) => key === b[index])
|
||||
|
||||
function diffKind<T>(
|
||||
before: IndexedKind<T>,
|
||||
after: IndexedKind<T>,
|
||||
ordered: boolean
|
||||
): LayoutRecordChanges<T> | undefined {
|
||||
const set: T[] = []
|
||||
for (const [key, entry] of after.byKey) {
|
||||
if (before.byKey.get(key)?.json !== entry.json) {
|
||||
set.push(entry.record)
|
||||
}
|
||||
}
|
||||
const removed = [...before.byKey.keys()].filter((key) => !after.byKey.has(key))
|
||||
const order = ordered && !sameOrder(before.order, after.order) ? after.order : undefined
|
||||
if (set.length === 0 && removed.length === 0 && !order) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
...(set.length > 0 ? { set } : {}),
|
||||
...(removed.length > 0 ? { removed } : {}),
|
||||
...(order ? { order } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/** What changed between two published copies of one workspace; null when nothing did. */
|
||||
export function diffPublishedLayouts(
|
||||
key: string,
|
||||
before: IndexedLayoutRecords | null,
|
||||
after: IndexedLayoutRecords | null,
|
||||
layout: PublishedWorkspaceLayout
|
||||
): WorkspaceLayoutRecordChange | null {
|
||||
if (!before || !after) {
|
||||
return { key, change: 'layout', layout }
|
||||
}
|
||||
// Trees and bindings are placed by their tabs, so only the other kinds publish an order.
|
||||
const tabs = diffKind(before.tabs, after.tabs, true)
|
||||
const paneTrees = diffKind(before.paneTrees, after.paneTrees, false)
|
||||
const bindings = diffKind(before.bindings, after.bindings, false)
|
||||
const editorFiles = diffKind(before.editorFiles, after.editorFiles, true)
|
||||
const browserTabs = diffKind(before.browserTabs, after.browserTabs, true)
|
||||
const changes: WorkspaceLayoutRecordChanges = {
|
||||
...(before.workspaceJson !== after.workspaceJson ? { workspace: after.workspace } : {}),
|
||||
...(tabs ? { tabs } : {}),
|
||||
...(paneTrees ? { paneTrees } : {}),
|
||||
...(bindings ? { bindings } : {}),
|
||||
...(editorFiles ? { editorFiles } : {}),
|
||||
...(browserTabs ? { browserTabs } : {})
|
||||
}
|
||||
return Object.keys(changes).length > 0 ? { key, change: 'records', ...changes } : null
|
||||
}
|
||||
|
||||
function applyKind<T>(
|
||||
records: T[],
|
||||
changes: LayoutRecordChanges<T> | undefined,
|
||||
keyOf: (record: T) => string,
|
||||
ordered: boolean
|
||||
): T[] {
|
||||
if (!changes) {
|
||||
return records
|
||||
}
|
||||
const byKey = new Map(records.map((record) => [keyOf(record), record]))
|
||||
for (const key of changes.removed ?? []) {
|
||||
byKey.delete(key)
|
||||
}
|
||||
for (const record of changes.set ?? []) {
|
||||
byKey.set(keyOf(record), record)
|
||||
}
|
||||
if (!ordered) {
|
||||
return [...byKey.values()]
|
||||
}
|
||||
const order = changes.order ?? records.map(keyOf)
|
||||
const placed = new Set(order)
|
||||
if ([...byKey.keys()].some((key) => !placed.has(key))) {
|
||||
throw new Error('layout records: a record is missing from its order')
|
||||
}
|
||||
return order.map((key) => {
|
||||
const record = byKey.get(key)
|
||||
if (record === undefined) {
|
||||
throw new Error(`layout records: order names a missing record ${key}`)
|
||||
}
|
||||
return record
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* One workspace's change applied to what was last shown for it; undefined once removed. Throws
|
||||
* when the change does not fit `before` (a record change for a workspace never shown, a broken
|
||||
* order): the reader's copy is no longer the host's and must be fetched again.
|
||||
*/
|
||||
export function applyWorkspaceLayoutRecordChange(
|
||||
before: PublishedWorkspaceLayout | undefined,
|
||||
change: WorkspaceLayoutRecordChange
|
||||
): PublishedWorkspaceLayout | undefined {
|
||||
if (change.change === 'removed') {
|
||||
return undefined
|
||||
}
|
||||
if (change.change === 'layout') {
|
||||
return change.layout
|
||||
}
|
||||
if (!before) {
|
||||
throw new Error(`layout records: a change for unknown workspace ${change.key}`)
|
||||
}
|
||||
const records = splitPublishedLayout(before)
|
||||
return joinPublishedLayout({
|
||||
workspace: change.workspace ?? records.workspace,
|
||||
tabs: applyKind(records.tabs, change.tabs, RECORD_KEYS.tabs, true),
|
||||
paneTrees: applyKind(records.paneTrees, change.paneTrees, RECORD_KEYS.paneTrees, false),
|
||||
bindings: applyKind(records.bindings, change.bindings, RECORD_KEYS.bindings, false),
|
||||
editorFiles: applyKind(records.editorFiles, change.editorFiles, RECORD_KEYS.editorFiles, true),
|
||||
browserTabs: applyKind(records.browserTabs, change.browserTabs, RECORD_KEYS.browserTabs, true)
|
||||
})
|
||||
}
|
||||
@@ -5,7 +5,10 @@ import { nameBasedLoadContext } from './workspace-layout-minted-ids'
|
||||
import { localDesktopSession } from './workspace-layout-profile.test-fixture'
|
||||
import { publishWorkspaceLayout } from './workspace-layout-published'
|
||||
import { GIT_KEY } from './workspace-layout-session.test-fixture'
|
||||
import { readWorkspaceLayoutStreamFrame } from './workspace-layout-stream-frames'
|
||||
import {
|
||||
isKnownWorkspaceLayoutFrameType,
|
||||
readWorkspaceLayoutStreamFrame
|
||||
} from './workspace-layout-stream-frames'
|
||||
|
||||
function publishedLayout() {
|
||||
const { layout } = loadWorkspaceLayout(
|
||||
@@ -73,4 +76,37 @@ describe('readWorkspaceLayoutStreamFrame', () => {
|
||||
expect(readWorkspaceLayoutStreamFrame(unreadable)).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('reads a records frame and a snapshot’s epoch, and refuses records it cannot key', () => {
|
||||
const layout = JSON.parse(JSON.stringify(publishedLayout()))
|
||||
const records = {
|
||||
type: 'records',
|
||||
epoch: 'epoch-1',
|
||||
seq: 4,
|
||||
workspaces: [
|
||||
{
|
||||
key: GIT_KEY,
|
||||
change: 'records',
|
||||
bindings: { set: [{ leafId: 'l1', ptyGeneration: 2 }] }
|
||||
},
|
||||
{ key: 'b', change: 'layout', layout },
|
||||
{ key: 'c', change: 'removed' }
|
||||
]
|
||||
}
|
||||
expect(readWorkspaceLayoutStreamFrame(records)).toEqual(records)
|
||||
const snapshot = { type: 'snapshot', workspaces: [], seq: 3, epoch: 'epoch-1' }
|
||||
expect(readWorkspaceLayoutStreamFrame(snapshot)).toEqual(snapshot)
|
||||
for (const unreadable of [
|
||||
{ ...records, epoch: undefined },
|
||||
{ ...records, seq: undefined },
|
||||
{ ...records, workspaces: [{ key: GIT_KEY, change: 'renamed' }] },
|
||||
{ ...records, workspaces: [{ key: GIT_KEY, change: 'records', tabs: { removed: [1] } }] },
|
||||
{ ...records, workspaces: [{ key: GIT_KEY, change: 'layout', layout: {} }] }
|
||||
]) {
|
||||
expect(readWorkspaceLayoutStreamFrame(unreadable)).toBeNull()
|
||||
// Known but malformed: a reader resubscribes rather than skip it.
|
||||
expect(isKnownWorkspaceLayoutFrameType(unreadable)).toBe(true)
|
||||
}
|
||||
expect(isKnownWorkspaceLayoutFrameType({ type: 'navigate' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// one client that reads them.
|
||||
|
||||
import type { PublishedWorkspaceLayout } from './workspace-layout-published'
|
||||
import type { WorkspaceLayoutRecordChange } from './workspace-layout-records'
|
||||
|
||||
/** `seq`: the stream's position after this frame. It rises by one per change frame for the host's
|
||||
* life, so a subscriber to every workspace sees each one. A `layout.apply` reply names the
|
||||
@@ -14,6 +15,21 @@ export type WorkspaceLayoutEvent = (
|
||||
) &
|
||||
Sequenced
|
||||
|
||||
/**
|
||||
* A layout-owner host's one frame per operation: the records it changed, workspace by workspace.
|
||||
* `epoch` names the host's stream for its life; a reader whose epoch differs, or whose last
|
||||
* position is not `seq - 1`, missed frames and must take a fresh snapshot.
|
||||
*/
|
||||
export type WorkspaceLayoutRecordsFrame = {
|
||||
type: 'records'
|
||||
epoch: string
|
||||
seq: number
|
||||
workspaces: WorkspaceLayoutRecordChange[]
|
||||
}
|
||||
|
||||
/** What a host's stream hands its subscribers: an older host's per-workspace events, or records. */
|
||||
export type WorkspaceLayoutStreamEvent = WorkspaceLayoutEvent | WorkspaceLayoutRecordsFrame
|
||||
|
||||
export type WorkspaceLayoutSnapshotEntry = { key: string; layout: PublishedWorkspaceLayout }
|
||||
|
||||
export type WorkspaceLayoutStreamFrame =
|
||||
@@ -22,8 +38,10 @@ export type WorkspaceLayoutStreamFrame =
|
||||
type: 'snapshot'
|
||||
subscriptionId?: string
|
||||
workspaces: WorkspaceLayoutSnapshotEntry[]
|
||||
/** A layout-owner host's stream epoch; its records frames carry the same one. */
|
||||
epoch?: string
|
||||
} & Sequenced)
|
||||
| WorkspaceLayoutEvent
|
||||
| WorkspaceLayoutStreamEvent
|
||||
| { type: 'end' }
|
||||
|
||||
/** What a reader's cache takes from the stream. */
|
||||
@@ -64,6 +82,62 @@ function isSnapshotEntry(value: unknown): value is WorkspaceLayoutSnapshotEntry
|
||||
return isRecord(value) && isKey(value.key) && isPublishedWorkspaceLayout(value.layout)
|
||||
}
|
||||
|
||||
const RECORD_KINDS = ['tabs', 'paneTrees', 'bindings', 'editorFiles', 'browserTabs'] as const
|
||||
|
||||
const isKeyList = (value: unknown) =>
|
||||
value === undefined || (Array.isArray(value) && value.every(isKey))
|
||||
|
||||
function isRecordChanges(value: unknown): boolean {
|
||||
return (
|
||||
value === undefined ||
|
||||
(isRecord(value) &&
|
||||
(value.set === undefined || (Array.isArray(value.set) && value.set.every(isRecord))) &&
|
||||
isKeyList(value.removed) &&
|
||||
isKeyList(value.order))
|
||||
)
|
||||
}
|
||||
|
||||
// Top-level shape only, as for a whole layout: the records' fields are the host's contract.
|
||||
function isRecordChange(value: unknown): value is WorkspaceLayoutRecordChange {
|
||||
if (!isRecord(value) || !isKey(value.key)) {
|
||||
return false
|
||||
}
|
||||
switch (value.change) {
|
||||
case 'removed':
|
||||
return true
|
||||
case 'layout':
|
||||
return isPublishedWorkspaceLayout(value.layout)
|
||||
case 'records':
|
||||
return (
|
||||
(value.workspace === undefined ||
|
||||
(isRecord(value.workspace) &&
|
||||
typeof value.workspace.worktreeId === 'string' &&
|
||||
Array.isArray(value.workspace.groups))) &&
|
||||
RECORD_KINDS.every((kind) => isRecordChanges(value[kind]))
|
||||
)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function readRecordsFrame(value: Record<string, unknown>): WorkspaceLayoutRecordsFrame | null {
|
||||
const seq = readWorkspaceLayoutFramePosition(value)
|
||||
return typeof value.epoch === 'string' &&
|
||||
value.epoch.length > 0 &&
|
||||
seq !== undefined &&
|
||||
Array.isArray(value.workspaces) &&
|
||||
value.workspaces.every(isRecordChange)
|
||||
? { type: 'records', epoch: value.epoch, seq, workspaces: value.workspaces }
|
||||
: null
|
||||
}
|
||||
|
||||
const FRAME_TYPES: readonly unknown[] = ['snapshot', 'workspace', 'removed', 'records', 'end']
|
||||
|
||||
/** A type this build reads: a newer host's other types are skipped, never a reason to resync. */
|
||||
export function isKnownWorkspaceLayoutFrameType(value: unknown): boolean {
|
||||
return isRecord(value) && FRAME_TYPES.includes(value.type)
|
||||
}
|
||||
|
||||
/** Null for a malformed frame or a frame type this build does not know (a newer host's). */
|
||||
export function readWorkspaceLayoutStreamFrame(value: unknown): WorkspaceLayoutStreamFrame | null {
|
||||
if (!isRecord(value)) {
|
||||
@@ -71,9 +145,18 @@ export function readWorkspaceLayoutStreamFrame(value: unknown): WorkspaceLayoutS
|
||||
}
|
||||
switch (value.type) {
|
||||
case 'snapshot':
|
||||
return Array.isArray(value.workspaces) && value.workspaces.every(isSnapshotEntry)
|
||||
? { type: 'snapshot', workspaces: value.workspaces, ...sequenced(value) }
|
||||
return Array.isArray(value.workspaces) &&
|
||||
value.workspaces.every(isSnapshotEntry) &&
|
||||
(value.epoch === undefined || isKey(value.epoch))
|
||||
? {
|
||||
type: 'snapshot',
|
||||
workspaces: value.workspaces,
|
||||
...sequenced(value),
|
||||
...(isKey(value.epoch) ? { epoch: value.epoch } : {})
|
||||
}
|
||||
: null
|
||||
case 'records':
|
||||
return readRecordsFrame(value)
|
||||
case 'workspace':
|
||||
return isKey(value.key) && isPublishedWorkspaceLayout(value.layout)
|
||||
? { type: 'workspace', key: value.key, layout: value.layout, ...sequenced(value) }
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
WorkspaceLayoutModel
|
||||
} from '../../../src/shared/workspace-layout/workspace-layout-model'
|
||||
import type { PublishedWorkspaceLayout } from '../../../src/shared/workspace-layout/workspace-layout-published'
|
||||
import { applyWorkspaceLayoutRecordChange } from '../../../src/shared/workspace-layout/workspace-layout-records'
|
||||
import {
|
||||
readWorkspaceLayoutStreamFrame,
|
||||
type WorkspaceLayoutChangeFrame
|
||||
@@ -52,7 +53,12 @@ export class LiveLayoutFeed {
|
||||
this.ready = false
|
||||
return
|
||||
}
|
||||
this.applyChange(frame)
|
||||
try {
|
||||
this.applyChange(frame)
|
||||
} catch (error) {
|
||||
this.fail(`layout frame does not apply: ${String(error)}`)
|
||||
return
|
||||
}
|
||||
this.frames += 1
|
||||
this.onFrame(this)
|
||||
}
|
||||
@@ -68,6 +74,17 @@ export class LiveLayoutFeed {
|
||||
return
|
||||
case 'removed':
|
||||
this.workspaces.delete(frame.key)
|
||||
return
|
||||
case 'records':
|
||||
// A layout-owner host's one frame per operation: each workspace's changed records.
|
||||
for (const change of frame.workspaces) {
|
||||
const layout = applyWorkspaceLayoutRecordChange(this.workspaces.get(change.key), change)
|
||||
if (layout) {
|
||||
this.workspaces.set(change.key, layout)
|
||||
} else {
|
||||
this.workspaces.delete(change.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user