Files
orca/src/shared/workspace-session-schema.ts
T
Neil 2038376d8e fix(terminal): a park must not discard the only copy of a remote pane's scrollback (#21285)
* fix(terminal): keep a client copy of a parked remote pane's scrollback

A remote-runtime pty's bytes never transit the client's main process, so the pane's
xterm buffer is the only client-side copy. The ordinary cold-park unmounted that pane
without capturing it, licensed by TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY — a static
build string that says nothing about whether the host retained this pty's buffer. On
reveal, a host that answers 'no-serializable-buffer' (or stays silent past the request
timeout) collapses to a null snapshot and the pane paints blank: tabs and splits survive,
the scrollback is gone.

Capture before every park, not only the retention-budget force-park, so the reveal has a
copy to replay when the host cannot answer. An unverifiable host answer is not proof the
pane was empty; keep the buffer, never discard it.

Adds ORCA_E2E_FORCE_REMOTE_TERMINAL_SNAPSHOT_UNAVAILABLE so an e2e can reproduce the
host-retains-nothing state, mirroring the existing forced-truncation lever.

* test(terminal): prove a parked remote pane survives a host that answers nothing

The oracle is a token the test types into the terminal before the park and the fixture
echoes back. Nothing replays stdin, so a respawned command cannot reproduce that line —
only the pre-park buffer can. An earlier argv marker passed vacuously for exactly that
reason.

The control ('host retains the buffer') is insensitive to the fix and fails if the harness
never parks, never reveals, or never echoed the token, so the regression case cannot be
green for a harness reason.

* refactor(terminal): validate the paired host terminal RPC shape instead of casting it

The merge-commit consistent-type-assertions gate flags every new `as`. Two were fixture
shapes that a type annotation states directly, and the third hid an unchecked RPC payload —
readCreatedTerminalTab now fails with the shape named rather than surfacing later as an
undefined surface id.

* fix(terminal): let a park capture survive an unhydrated repo catalog

Reading state.repos unguarded threw out of the cold-park effect whenever the catalog was
absent, which would break parking itself. Capture is best-effort evidence; an empty catalog
also fails open in shouldPreserveTerminalScrollbackBuffers, the safe direction for a park.

* docs(terminal): pin why the two unhydrated-catalog fallbacks point opposite ways

shouldPreserveTerminalScrollbackBuffers fails open toward 'remote' because a worktree wrongly
judged local parks with no copy at all. worktree-runtime-owner.ts resolves the same unhydrated
catalog to 'local', which is safe there and would be data loss here. A reader pattern-matching
'fail open' across the two gets one of them backwards.

* fix(terminal): keep a parked pane's scrollback across a reconnect merge

The direct-SSH pull replaces a replaced tab's layout wholesale, and a park capture does not
bump tab.generation — so a just-parked tab is not in locallyPreservedTabIds and the only
client-side copy of its remote scrollback went with the layout it replaced. That is the same
data loss this branch already fixes, one layer down, and it is the layer that decides whether
the fix survives the app update the user actually performed.

Carry the client's leaf-keyed scrollback into the host's layout, filtered to the host's own
root leaves. Structure stays the host's verbatim, so a split it added while we were away still
wins and a leaf it retired still drops its bytes. Local wins a conflict: neither copy is then
the only one, but remote-wins would overwrite the tail captured since the last upload and
propagate that backwards on the next replace-session patch.

Not a generation bump: the pane key is `${tab.id}-${tab.generation}`, so bumping would remount
the pane and destroy the very buffer the capture just serialized, lift the recovery-storm
ledger ceiling, and let a stale local ptyId win through preserveNewerLocalTerminalFields.

* fix(terminal): carry a parked pane's scrollback through the mirrored-layout rebuild

Found in review of this PR by rc-ssh-remoting. chooseRemoteTerminalLayout rebuilds a
mirrored tab's layout from the host's picture and never carried buffersByLeafId or
scrollbackRefsByLeafId forward, though it already receives existingLayout. The host
publishes no scrollback of its own, so ANY session-inventory frame landing between park and
reveal dropped the only client-side copy: the rebuild is bufferless, terminalLayoutEqual
compares buffers so the write is not bailed out, and apply-terminal-records assigns it
wholesale.

Measured before the fix: 336 bytes captured at park, 0 after one forced frame, blank pane on
reveal. After: 411 bytes survive the frame and the reveal repaints.

The e2e passed either way because no frame happened to land in its window, so it was not
covering the destroying event. It now forces one inside the park -> reveal window and asserts
the capture survives it.

An identical fix was written and reverted earlier in this branch as 'no measurable effect' —
that measurement ran on a harness deleting the client profile between launches, so nothing
downstream of persistence could register. It was never actually tested.

* feat(session): add a local-only home for ordinary-park scrollback

localOnlyScrollbackByTabId is a top-level session field, tabId -> leafId -> buffer, that never
rides the remote projection: exportRemoteWorkspaceSession is an explicit allowlist of named
top-level fields, so a new one is omitted for free, whereas anything added to
TerminalLayoutSnapshot is copied whole. It is also outside the two records the mirrored-tab apply
rewrites, so a host inventory frame cannot wipe it.

Registered in every exhaustive session registry ('tabKeyed'), hydrated and scoped like the layout
map, dropped with its tab on close/removal/purge/repo removal/mirrored retirement, copied on profile
transfer, emitted by the incremental patch builder, and capped by pruneLocalTerminalScrollbackBuffers
alongside the shared home — with a per-home test so an uncapped path cannot go unnoticed.

Known ceiling, not widened here: the field routes through the partition router that falls back to
'local' when the repo catalog is unknown at write time (#21295).

* fix(terminal): keep ordinary-park scrollback off the upload, and read both homes through one resolver

The ordinary cold park fires on every workspace hide. Its capture now splits: structure (root,
ptyIds, titles) stays in the shared layout, bytes go to localOnlyScrollbackByTabId. Force-park,
hibernate, sleep and shutdown keep writing buffersByLeafId, because that copy is what a second
desktop cold-restores from; a shared capture clears the local copy so the two homes never hold two
versions of one leaf.

resolveLeafScrollbackBuffers is the only read across the two homes (local wins a conflict: it is
the later write by construction). restoreTerminalPaneLayout no longer reads buffersByLeafId
directly, the capture's merge prior comes from the resolver, and the post-replay release covers
both homes.

Measured with the projection at 20 tabs x 2 panes at the per-leaf cap: the shared-layout shape
exports ~22 MiB per replace-session; the local-only shape exports the bufferless baseline.

* test(sync): pin that the mirrored rebuild carries the client scrollback refs

The carry-through added in f210dece83 keeps scrollbackRefsByLeafId for leaves the host still
names (a ref is the only pointer to a local scrollback file), so the rebuilt layout equals the
stored one and the write bails. The old assertion expected the refs to be dropped and has been red
on this branch since that commit.

* test(e2e): assert where a park's bytes land, and re-point the inventory-frame check at the force-park

Once ordinary parks stop writing buffersByLeafId, the existing survivedInventoryFrame assertion
passes trivially — there is nothing in the layout to wipe. The ordinary scenarios now assert the
store-level upload contract (bytes in the local-only home, shared home empty) and that the local
home is out of a host frame's reach; a third scenario reaches a force-park (host without paired
parking, client retention limit 1) and asserts the shared capture survives the forced frame, which
is where the mirrored-layout carry-through is load-bearing.

* test(e2e): leave the force-park reveal out of the assertion instead of matching it loosely

expect.any(Boolean) reads as a check but cannot fail. The force-park reveal is served by the host
tail on a host without paired parking, so it is logged for diagnosis and the assertion carries the
three fields that are deterministic on this topology.
2026-09-18 19:49:38 -07:00

366 lines
15 KiB
TypeScript

/* Why: the workspace session JSON is written to disk by older builds and read
* back by newer ones. A field type flip (e.g. ptyId going from string to an
* object) or a truncated write could poison Zustand state and crash the
* renderer on mount. Schema-validating at the read boundary gives us a single
* "reject and fall back to defaults" point so garbage never reaches React.
*
* Policy: be tolerant of extra fields (future builds may add more) but strict
* about the types of fields we actually read. Where a field holds a collection
* of independent records, tolerance is declared on the field itself (see
* ./zod-salvage): a corrupt entry is dropped and the rest of the session
* survives, because one bad tab record must not cost every worktree its state.
* Only a payload that is not a session at all falls back to defaults.
*/
import { z } from 'zod'
import { closedTerminalTabTombstoneSchema } from './closed-terminal-tab-tombstones'
import type { WorkspaceKey } from './folder-workspace-types'
import type { TabGroupLayoutNode } from './tab-types'
import type { TerminalPaneLayoutNode } from './terminal-tab-types'
import type { TuiAgent } from './tui-agent'
import type { WorkspaceSessionState } from './workspace-session-state-types'
import { terminalTabIdSchema } from './terminal-tab-id-schema'
import { terminalSurfaceTombstoneSchema } from './terminal-surface-tombstone-schema'
import { parseExecutionHostId, type ExecutionHostId } from './execution-host'
import { isTuiAgent } from './tui-agent-config'
import { isWorkspaceKey } from './workspace-scope'
import {
browserHistoryEntriesSchema,
workspaceDocHistoryEntriesSchema,
browserPageSchema,
browserWorkspaceSchema
} from './workspace-session-browser-schema'
import { clientHostedBrowserCloseIntentSchema } from './client-hosted-browser-close-intent'
import { persistedClientHostedBrowserPageSchema } from './client-hosted-browser-page-record'
import { persistedOpenFileSchema } from './workspace-session-editor-schema'
import { sleepingAgentSessionsByPaneKeySchema } from './workspace-session-sleeping-agents'
import {
tabContentTypeSchema,
workspaceVisibleTabTypeSchema
} from './workspace-session-tab-type-schema'
import { salvagedField, salvagedOptional, salvagingArray, salvagingRecord } from './zod-salvage'
// ─── Terminal pane layout (recursive) ───────────────────────────────
const terminalPaneSplitDirectionSchema = z.enum(['vertical', 'horizontal'])
const workspaceKeySchema = z.custom<WorkspaceKey>(
(value) => typeof value === 'string' && isWorkspaceKey(value)
)
// Why: z.lazy + type annotation keeps the recursive inference working without
// forcing zod to resolve the whole tree at definition time. Discriminated on `type` because a
// plain union re-tries the leaf branch for every split node of every restored terminal layout.
const terminalPaneLayoutNodeSchema: z.ZodType<TerminalPaneLayoutNode> = z.lazy(() =>
z.discriminatedUnion('type', [
z.object({
type: z.literal('leaf'),
leafId: z.string()
}),
z.object({
type: z.literal('split'),
direction: terminalPaneSplitDirectionSchema,
first: terminalPaneLayoutNodeSchema,
second: terminalPaneLayoutNodeSchema,
ratio: z.number().optional()
})
])
)
const leafStringsSchema = salvagingRecord(z.string(), z.string())
const terminalLayoutSnapshotSchema = z.object({
root: terminalPaneLayoutNodeSchema.nullable(),
activeLeafId: z.string().nullable(),
expandedLeafId: z.string().nullable(),
ptyIdsByLeafId: salvagedOptional('ptyIdsByLeafId', leafStringsSchema),
buffersByLeafId: salvagedOptional('buffersByLeafId', leafStringsSchema),
scrollbackRefsByLeafId: salvagedOptional('scrollbackRefsByLeafId', leafStringsSchema),
titlesByLeafId: salvagedOptional('titlesByLeafId', leafStringsSchema)
})
// ─── Terminal tab (legacy) ──────────────────────────────────────────
const terminalTabSchema = z.object({
id: terminalTabIdSchema,
ptyId: z.string().nullable(),
worktreeId: z.string(),
title: z.string(),
defaultTitle: z.string().optional(),
generatedTitle: z.string().nullable().optional(),
aiVaultTitle: z
.object({
agent: z.enum(['claude', 'codex']),
sessionId: z.string(),
title: z.string()
})
.nullable()
.optional()
.catch(undefined),
quickCommandLabel: z.string().nullable().optional(),
customTitle: z.string().nullable(),
color: z.string().nullable(),
isPinned: z.boolean().optional(),
// Why: recovery asks the terminal row who owns the surface, so a row that
// loses viewMode on reload reads as "not chat-owned" and lets a hidden chat
// surface remount itself. Declared here so the row survives the parse, with
// the same `.catch('terminal')` degradation the unified tab uses below.
// Legacy rows that predate this stay undefined → 'terminal' in the renderer.
viewMode: z.enum(['terminal', 'chat']).catch('terminal').optional(),
sortOrder: z.number(),
createdAt: z.number(),
generation: z.number().optional(),
startupCwd: z.string().min(1).optional(),
// Why: persist the launched agent so a restored idle agent tab keeps its
// provider icon before any hook fires. `.catch(undefined)` keeps a stale or
// unknown agent id from failing the whole-session parse (which would reset
// every terminal/editor/browser to defaults).
launchAgent: z
.custom<TuiAgent>((v) => isTuiAgent(v))
.optional()
.catch(undefined)
})
// ─── Unified tab model ──────────────────────────────────────────────
const executionHostIdSchema = z.custom<ExecutionHostId>(
(value) => typeof value === 'string' && Boolean(parseExecutionHostId(value))
)
const tabSchema = z.object({
id: z.string(),
entityId: z.string(),
groupId: z.string(),
worktreeId: z.string(),
executionHostId: executionHostIdSchema.optional(),
contentType: tabContentTypeSchema,
agentSessionAgent: z.enum(['codex', 'claude']).optional().catch(undefined),
// Why: a structured terminal tab must recover its durable host session after
// restart; omitting this additive field silently routes it back through PTY.
structuredSessionId: z.string().min(1).optional().catch(undefined),
label: z.string(),
generatedLabel: z.string().nullable().optional(),
aiVaultTitle: z
.object({
agent: z.enum(['claude', 'codex']),
sessionId: z.string(),
title: z.string()
})
.nullable()
.optional()
.catch(undefined),
quickCommandLabel: z.string().nullable().optional(),
customLabel: z.string().nullable(),
color: z.string().nullable(),
sortOrder: z.number(),
createdAt: z.number(),
// Why: corrupt optional recency must not discard the whole persisted tab.
lastFocusedAt: z.number().finite().nonnegative().optional().catch(undefined),
isPreview: z.boolean().optional(),
isPinned: z.boolean().optional(),
// Why: persist the per-tab native-chat view mode so 'chat' survives reload /
// session restore. `.catch('terminal')` tolerates unknown future values (a
// newer build that wrote an unrecognized mode) by degrading to the safe
// default instead of failing the whole-session parse. Legacy/missing stays
// undefined → 'terminal' in the renderer.
viewMode: z.enum(['terminal', 'chat']).catch('terminal').optional()
})
const tabGroupSchema = z.object({
id: z.string(),
worktreeId: z.string(),
activeTabId: z.string().nullable(),
tabOrder: z.array(z.string()),
recentTabIds: z.array(z.string()).optional()
})
const tabGroupSplitDirectionSchema = z.enum(['horizontal', 'vertical'])
const tabGroupLayoutNodeSchema: z.ZodType<TabGroupLayoutNode> = z.lazy(() =>
z.discriminatedUnion('type', [
z.object({
type: z.literal('leaf'),
groupId: z.string()
}),
z.object({
type: z.literal('split'),
direction: tabGroupSplitDirectionSchema,
first: tabGroupLayoutNodeSchema,
second: tabGroupLayoutNodeSchema,
ratio: z.number().optional()
})
])
)
// ─── Workspace session ──────────────────────────────────────────────
const worktreeIdSchema = z.string()
export const workspaceSessionStateSchema: z.ZodType<WorkspaceSessionState> = z.object({
activeRepoId: salvagedField('activeRepoId', z.string().nullable(), () => null),
activeWorkspaceKey: salvagedOptional('activeWorkspaceKey', workspaceKeySchema.nullable()),
activeWorkspaceExecutionHostId: salvagedOptional(
'activeWorkspaceExecutionHostId',
executionHostIdSchema.nullable()
),
activeWorktreeId: salvagedField('activeWorktreeId', z.string().nullable(), () => null),
activeTabId: salvagedField('activeTabId', z.string().nullable(), () => null),
tabsByWorktree: salvagedField(
'tabsByWorktree',
salvagingRecord(worktreeIdSchema, salvagingArray(terminalTabSchema)),
() => ({})
),
terminalLayoutsByTabId: salvagedField(
'terminalLayoutsByTabId',
salvagingRecord(terminalTabIdSchema, terminalLayoutSnapshotSchema),
() => ({})
),
// Client-local park scrollback; see WorkspaceSessionState.localOnlyScrollbackByTabId for why it is
// not a field on the layout snapshot. Optional so an older profile simply carries none.
localOnlyScrollbackByTabId: salvagedOptional(
'localOnlyScrollbackByTabId',
salvagingRecord(terminalTabIdSchema, leafStringsSchema)
),
activeWorktreeIdsOnShutdown: salvagedOptional(
'activeWorktreeIdsOnShutdown',
salvagingArray(worktreeIdSchema)
),
openFilesByWorktree: salvagedOptional(
'openFilesByWorktree',
salvagingRecord(worktreeIdSchema, salvagingArray(persistedOpenFileSchema))
),
activeFileIdByWorktree: salvagedOptional(
'activeFileIdByWorktree',
salvagingRecord(worktreeIdSchema, z.string().nullable())
),
markdownFrontmatterVisible: salvagedOptional(
'markdownFrontmatterVisible',
salvagingRecord(z.string(), z.boolean())
),
browserTabsByWorktree: salvagedOptional(
'browserTabsByWorktree',
salvagingRecord(worktreeIdSchema, salvagingArray(browserWorkspaceSchema))
),
browserPagesByWorkspace: salvagedOptional(
'browserPagesByWorkspace',
salvagingRecord(z.string(), salvagingArray(browserPageSchema))
),
activeBrowserTabIdByWorktree: salvagedOptional(
'activeBrowserTabIdByWorktree',
salvagingRecord(worktreeIdSchema, z.string().nullable())
),
clientHostedBrowserPagesByWorktree: salvagedOptional(
'clientHostedBrowserPagesByWorktree',
salvagingRecord(worktreeIdSchema, salvagingArray(persistedClientHostedBrowserPageSchema))
),
clientHostedBrowserCloseIntentsByEnvironment: salvagedOptional(
'clientHostedBrowserCloseIntentsByEnvironment',
salvagingRecord(z.string().min(1), salvagingArray(clientHostedBrowserCloseIntentSchema))
),
activeTabTypeByWorktree: salvagedOptional(
'activeTabTypeByWorktree',
salvagingRecord(worktreeIdSchema, workspaceVisibleTabTypeSchema)
),
browserUrlHistory: salvagedOptional('browserUrlHistory', browserHistoryEntriesSchema),
workspaceDocHistory: salvagedOptional('workspaceDocHistory', workspaceDocHistoryEntriesSchema),
activeTabIdByWorktree: salvagedOptional(
'activeTabIdByWorktree',
salvagingRecord(worktreeIdSchema, z.string().nullable())
),
unifiedTabs: salvagedOptional(
'unifiedTabs',
salvagingRecord(worktreeIdSchema, salvagingArray(tabSchema))
),
tabGroups: salvagedOptional(
'tabGroups',
salvagingRecord(worktreeIdSchema, salvagingArray(tabGroupSchema))
),
tabGroupLayouts: salvagedOptional(
'tabGroupLayouts',
salvagingRecord(worktreeIdSchema, tabGroupLayoutNodeSchema)
),
activeGroupIdByWorktree: salvagedOptional(
'activeGroupIdByWorktree',
salvagingRecord(worktreeIdSchema, z.string())
),
activeConnectionIdsAtShutdown: salvagedOptional(
'activeConnectionIdsAtShutdown',
salvagingArray(z.string())
),
remoteSessionIdsByTabId: salvagedOptional(
'remoteSessionIdsByTabId',
salvagingRecord(terminalTabIdSchema, z.string())
),
// Why: the sort comparator in order-empty-query-worktrees.ts would produce NaN
// (undefined sort order) from a NaN or Infinity persisted here.
lastVisitedAtByWorktreeId: salvagedOptional(
'lastVisitedAtByWorktreeId',
salvagingRecord(worktreeIdSchema, z.number().finite().nonnegative())
),
defaultTerminalTabsAppliedByWorktreeId: salvagedOptional(
'defaultTerminalTabsAppliedByWorktreeId',
salvagingRecord(worktreeIdSchema, z.literal(true))
),
sleepingAgentSessionsByPaneKey: salvagedOptional(
'sleepingAgentSessionsByPaneKey',
sleepingAgentSessionsByPaneKeySchema
),
terminalPtyIncarnationsByPaneKey: salvagedOptional(
'terminalPtyIncarnationsByPaneKey',
salvagingRecord(z.string(), z.string().min(1).max(128))
),
terminalTopologyRevisionByRepoId: salvagedOptional(
'terminalTopologyRevisionByRepoId',
salvagingRecord(z.string(), z.number().int().nonnegative())
),
terminalSurfaceTombstonesByPaneKey: salvagedOptional(
'terminalSurfaceTombstonesByPaneKey',
salvagingRecord(z.string(), terminalSurfaceTombstoneSchema)
),
closedTerminalTabTombstonesByTabId: salvagedOptional(
'closedTerminalTabTombstonesByTabId',
salvagingRecord(terminalTabIdSchema, closedTerminalTabTombstoneSchema)
)
})
export type ParsedWorkspaceSession =
| { ok: true; value: WorkspaceSessionState }
| { ok: false; error: string }
/** Why: keep the error compact — a zod issue dump is noisy and most of the time
* only the first divergent field is actionable for debugging. */
export function describeWorkspaceSessionError(error: z.ZodError): string {
const firstIssue = error.issues[0]
const path = firstIssue?.path.join('.') || '<root>'
return `${path}: ${firstIssue?.message ?? 'invalid session'}`
}
export const WORKSPACE_SESSION_UNVALIDATABLE = '<root>: session could not be validated'
/** safeParse, or null when the validator itself could not run.
* Why: safeParse is documented not to throw, but a payload holding hundreds of
* thousands of bad records overflows the stack while zod materializes an issue
* per field. This parse runs in the Store constructor, so an escaping RangeError
* is a launch failure the user cannot recover from without deleting their
* profile — exactly the "never throw into main" contract at the top of this file. */
export function safeParseWorkspaceSession(
raw: unknown
): ReturnType<typeof workspaceSessionStateSchema.safeParse> | null {
try {
return workspaceSessionStateSchema.safeParse(raw)
} catch {
return null
}
}
/** Validate raw JSON as a WorkspaceSessionState. Returns a discriminated union
* so callers can fall back to defaults on failure without a try/catch. */
export function parseWorkspaceSession(raw: unknown): ParsedWorkspaceSession {
const result = safeParseWorkspaceSession(raw)
if (!result) {
return { ok: false, error: WORKSPACE_SESSION_UNVALIDATABLE }
}
if (result.success) {
return { ok: true, value: result.data }
}
return { ok: false, error: describeWorkspaceSessionError(result.error) }
}