Files
orca/src/shared/workspace-layout/workspace-layout-stream-frames.ts
T
Jinwoo HongandE2E Test a2e0f20b1e 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>
2026-10-20 08:19:53 -04:00

172 lines
6.3 KiB
TypeScript

// The frames `layout.subscribe` sends (design 2.4), shared by the runtime that emits them and the
// 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
* position holding its effect. Absent from older hosts. */
type Sequenced = { seq?: number }
export type WorkspaceLayoutEvent = (
| { type: 'workspace'; key: string; layout: PublishedWorkspaceLayout }
| { type: 'removed'; key: string }
) &
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 =
// subscriptionId names the stream for `layout.unsubscribe`; a reader does not need it.
| ({
type: 'snapshot'
subscriptionId?: string
workspaces: WorkspaceLayoutSnapshotEntry[]
/** A layout-owner host's stream epoch; its records frames carry the same one. */
epoch?: string
} & Sequenced)
| WorkspaceLayoutStreamEvent
| { type: 'end' }
/** What a reader's cache takes from the stream. */
export type WorkspaceLayoutChangeFrame = Exclude<WorkspaceLayoutStreamFrame, { type: 'end' }>
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isKey(value: unknown): value is string {
return typeof value === 'string' && value.length > 0
}
// Checks the top-level shape only; the fields inside are the host's published contract.
function isPublishedWorkspaceLayout(value: unknown): value is PublishedWorkspaceLayout {
return (
isRecord(value) &&
typeof value.worktreeId === 'string' &&
Array.isArray(value.groups) &&
Array.isArray(value.tabs) &&
Array.isArray(value.editorFiles) &&
Array.isArray(value.browserTabs)
)
}
/** A frame's stream position, read even from a frame this build cannot read otherwise. */
export function readWorkspaceLayoutFramePosition(value: unknown): number | undefined {
const seq = isRecord(value) ? value.seq : undefined
return typeof seq === 'number' && Number.isSafeInteger(seq) && seq >= 0 ? seq : undefined
}
function sequenced(value: Record<string, unknown>): Sequenced {
const seq = readWorkspaceLayoutFramePosition(value)
return seq === undefined ? {} : { seq }
}
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)) {
return null
}
switch (value.type) {
case 'snapshot':
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) }
: null
case 'removed':
return isKey(value.key) ? { type: 'removed', key: value.key, ...sequenced(value) } : null
case 'end':
return { type: 'end' }
default:
return null
}
}