mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
fix(terminal): adopt live orphan PTYs safely
This commit is contained in:
@@ -885,6 +885,26 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
expect(procs[0].cwd).toBe('/repo/owned-before-osc7')
|
||||
expect(procs[0].worktreeId).toBe('repo::/repo/owned-before-osc7')
|
||||
})
|
||||
|
||||
it('reports the daemon session WSL owner', async () => {
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
try {
|
||||
const spawned = await adapter.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo'
|
||||
})
|
||||
|
||||
const procs = await adapter.listProcesses()
|
||||
|
||||
expect(procs.find((process) => process.id === spawned.id)?.wslDistro).toBe('Ubuntu')
|
||||
} finally {
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasChildProcesses / getForegroundProcess', () => {
|
||||
|
||||
@@ -921,6 +921,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
title: 'shell',
|
||||
...(worktreeId ? { worktreeId } : {}),
|
||||
...(s.terminalHandle ? { terminalHandle: s.terminalHandle } : {}),
|
||||
...(s.wslDistro !== undefined ? { wslDistro: s.wslDistro } : {}),
|
||||
...this.validatedAgentSessionOwners(s.agentSessionOwners)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -19,6 +19,7 @@ export function listLiveTerminalHostSessions(
|
||||
shellState: session.shellState,
|
||||
isAlive: true,
|
||||
...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}),
|
||||
wslDistro: session.wslDistro,
|
||||
pid: session.pid,
|
||||
cwd: session.getCwd(),
|
||||
cols: size?.cols ?? 0,
|
||||
|
||||
@@ -355,6 +355,7 @@ export type SessionInfo = {
|
||||
shellState: ShellReadyState
|
||||
isAlive: boolean
|
||||
terminalHandle?: string
|
||||
wslDistro?: string | null
|
||||
pid: number | null
|
||||
cwd: string | null
|
||||
cols: number
|
||||
|
||||
@@ -1789,6 +1789,26 @@ describe('LocalPtyProvider', () => {
|
||||
expect(newEntries[0]).toHaveProperty('cwd', '/tmp/owned-cwd')
|
||||
expect(newEntries[0]).toHaveProperty('worktreeId', 'repo::/tmp/owned-cwd')
|
||||
})
|
||||
|
||||
it('reports native and WSL ownership explicitly on Windows', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const native = await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: 'C:\\repo',
|
||||
shellOverride: 'powershell.exe'
|
||||
})
|
||||
const wsl = await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo'
|
||||
})
|
||||
|
||||
const processes = await provider.listProcesses()
|
||||
|
||||
expect(processes.find((process) => process.id === native.id)?.wslDistro).toBeNull()
|
||||
expect(processes.find((process) => process.id === wsl.id)?.wslDistro).toBe('Ubuntu')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDefaultShell', () => {
|
||||
|
||||
@@ -1323,7 +1323,8 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
cwd: ptyInitialCwd.get(id) ?? '',
|
||||
title: proc.process || ptyShellName.get(id) || 'shell',
|
||||
...(ptyWorktreeId.get(id) ? { worktreeId: ptyWorktreeId.get(id) } : {}),
|
||||
...(ptyTerminalHandle.get(id) ? { terminalHandle: ptyTerminalHandle.get(id) } : {})
|
||||
...(ptyTerminalHandle.get(id) ? { terminalHandle: ptyTerminalHandle.get(id) } : {}),
|
||||
...(ptyWslDistroById.has(id) ? { wslDistro: ptyWslDistroById.get(id) ?? null } : {})
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority'
|
||||
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
|
||||
|
||||
export type PtyProcessInfo = {
|
||||
id: string
|
||||
incarnationId?: PtyIncarnationId
|
||||
cwd: string
|
||||
title: string
|
||||
/** Owning worktree when the provider can report it authoritatively. */
|
||||
worktreeId?: string
|
||||
/** Trusted ORCA_TERMINAL_HANDLE exported into this PTY, when known. */
|
||||
terminalHandle?: string
|
||||
/** Exact WSL owner reported by the PTY provider; null means native Windows. */
|
||||
wslDistro?: string | null
|
||||
agentSessionOwners?: AgentSessionOwnerBinding[]
|
||||
}
|
||||
@@ -29,9 +29,9 @@ import type { PtySpawnResult } from './pty-spawn-result'
|
||||
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
|
||||
import type {
|
||||
AgentSessionExecutionClaim,
|
||||
AgentSessionOwnerBinding,
|
||||
AgentSessionSurfaceBinding
|
||||
} from '../../shared/agent-session-host-authority'
|
||||
import type { PtyProcessInfo } from './pty-process-info'
|
||||
|
||||
export type {
|
||||
PtyBackgroundStreamEvent,
|
||||
@@ -114,19 +114,7 @@ export type PtySpawnOptions = {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export type { PtySpawnResult }
|
||||
|
||||
export type PtyProcessInfo = {
|
||||
id: string
|
||||
incarnationId?: PtyIncarnationId
|
||||
cwd: string
|
||||
title: string
|
||||
/** Owning worktree when the provider can report it authoritatively. */
|
||||
worktreeId?: string
|
||||
/** Trusted ORCA_TERMINAL_HANDLE exported into this PTY, when known. */
|
||||
terminalHandle?: string
|
||||
agentSessionOwners?: AgentSessionOwnerBinding[]
|
||||
}
|
||||
export type { PtyProcessInfo, PtySpawnResult }
|
||||
|
||||
type PtyProbeOptions = { signal?: AbortSignal }
|
||||
|
||||
@@ -393,10 +381,7 @@ export type IGitProvider = {
|
||||
|
||||
// ─── Provider Registry ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Routes operations to the correct provider based on connectionId.
|
||||
* null/undefined connectionId = local provider.
|
||||
*/
|
||||
/** Routes operations by connectionId; null/undefined selects the local provider. */
|
||||
export type IProviderRegistry = {
|
||||
getPtyProvider(connectionId: string | null | undefined): IPtyProvider
|
||||
getFilesystemProvider(connectionId: string | null | undefined): IFilesystemProvider
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -368,6 +368,8 @@ import type {
|
||||
RuntimeTerminalFocus,
|
||||
RuntimeTerminalClose,
|
||||
RuntimeTerminalListResult,
|
||||
RuntimeTerminalOrphanAdoptionRequest,
|
||||
RuntimeTerminalOrphanAdoptionResult,
|
||||
RuntimeWorktreeTerminalSleepResult,
|
||||
RuntimeTerminalResolvePane,
|
||||
RuntimeTerminalState,
|
||||
@@ -426,6 +428,11 @@ import {
|
||||
buildHeadlessTabGroupMove,
|
||||
buildHeadlessTabGroupSplit
|
||||
} from './headless-tab-group-split-layout'
|
||||
import {
|
||||
hasExactTerminalOrphanGroupLayout,
|
||||
mergeTerminalOrphanGroupLayout
|
||||
} from './terminal-orphan-topology'
|
||||
import { terminalOrphanExecutionOwnersEqual } from './terminal-orphan-owner'
|
||||
import {
|
||||
retireTerminalSurfacesFromSnapshot,
|
||||
type RetiredTerminalSurface
|
||||
@@ -1134,6 +1141,7 @@ type RuntimePtyWorktreeRecord = {
|
||||
lastOscTitleAt: number | null
|
||||
managementTitle: string | null
|
||||
managementTitleAt: number | null
|
||||
controllerTitle: string | null
|
||||
title: string | null
|
||||
titleUpdatedAt: number | null
|
||||
lastOutputAt: number | null
|
||||
@@ -2462,6 +2470,10 @@ export class OrcaRuntimeService {
|
||||
private handles = new Map<string, TerminalHandleRecord>()
|
||||
private handleByLeafKey = new Map<string, string>()
|
||||
private handleByPtyId = new Map<string, string>()
|
||||
private controllerTerminalIdentityByPtyId = new Map<
|
||||
string,
|
||||
{ handle: string; incarnationId: string; wslDistro?: string | null }
|
||||
>()
|
||||
private detachedPreAllocatedLeaves = new Map<string, RuntimeLeafRecord>()
|
||||
private graphSyncCallbacks: (() => void)[] = []
|
||||
private waitersByHandle = new Map<string, Set<TerminalWaiter>>()
|
||||
@@ -7141,11 +7153,29 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
private adoptControllerTerminalHandle(ptyId: string, handle: string | undefined): void {
|
||||
private adoptControllerTerminalHandle(
|
||||
ptyId: string,
|
||||
handle: string | undefined,
|
||||
incarnationId?: string
|
||||
): void {
|
||||
const trimmed = handle?.trim()
|
||||
if (!trimmed || !trimmed.startsWith('term_')) {
|
||||
return
|
||||
}
|
||||
const pty = this.ptysById.get(ptyId)
|
||||
const changedIncarnation = Boolean(
|
||||
incarnationId && pty?.incarnationId && incarnationId !== pty.incarnationId
|
||||
)
|
||||
if (changedIncarnation) {
|
||||
const priorHandle = this.handleByPtyId.get(ptyId)
|
||||
this.invalidateAllHandlesForPty(ptyId)
|
||||
pty!.tabId = null
|
||||
pty!.paneKey = null
|
||||
// Reusing an exported handle would make stale client metadata name the replacement process.
|
||||
if (priorHandle === trimmed) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (this.isTerminalHandleAdoptionBlocked(ptyId, trimmed)) {
|
||||
return
|
||||
}
|
||||
@@ -7154,6 +7184,23 @@ export class OrcaRuntimeService {
|
||||
this.registerPreAllocatedHandleForPty(ptyId, trimmed)
|
||||
}
|
||||
|
||||
private invalidateAllHandlesForPty(ptyId: string): void {
|
||||
this.handleByPtyId.delete(ptyId)
|
||||
const invalidated = new Set<string>()
|
||||
for (const [handle, record] of this.handles) {
|
||||
if (record.ptyId === ptyId) {
|
||||
invalidated.add(handle)
|
||||
this.handles.delete(handle)
|
||||
this.rejectWaitersForHandle(handle, 'terminal_handle_stale')
|
||||
}
|
||||
}
|
||||
for (const [leafKey, handle] of this.handleByLeafKey) {
|
||||
if (invalidated.has(handle)) {
|
||||
this.handleByLeafKey.delete(leafKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: adoption is best-effort restart recovery and must be first-wins.
|
||||
// Re-keying a pty that already has a handle this session would strand
|
||||
// waiters registered under the old handle, and provider-reported values
|
||||
@@ -12417,7 +12464,7 @@ export class OrcaRuntimeService {
|
||||
async listTerminals(
|
||||
worktreeSelector?: string,
|
||||
limit = DEFAULT_TERMINAL_LIST_LIMIT,
|
||||
opts: { requireFreshPtyLiveness?: boolean } = {}
|
||||
opts: { handles?: readonly string[]; requireFreshPtyLiveness?: boolean } = {}
|
||||
): Promise<RuntimeTerminalListResult> {
|
||||
if (!Number.isInteger(limit) || limit <= 0) {
|
||||
throw new Error('invalid_limit')
|
||||
@@ -12529,7 +12576,11 @@ export class OrcaRuntimeService {
|
||||
terminals.push(this.buildPtyTerminalSummary(pty, worktreesById))
|
||||
}
|
||||
|
||||
const listedTerminals = terminals.slice(0, limit)
|
||||
const requestedHandles = opts.handles ? new Set(opts.handles) : null
|
||||
const matchingTerminals = requestedHandles
|
||||
? terminals.filter((terminal) => requestedHandles.has(terminal.handle))
|
||||
: terminals
|
||||
const listedTerminals = matchingTerminals.slice(0, limit)
|
||||
const visualLayouts = this.buildTerminalVisualLayouts(
|
||||
listedTerminals,
|
||||
worktreesById,
|
||||
@@ -12539,8 +12590,480 @@ export class OrcaRuntimeService {
|
||||
return {
|
||||
terminals: listedTerminals,
|
||||
...(visualLayouts.length > 0 ? { visualLayouts } : {}),
|
||||
totalCount: terminals.length,
|
||||
truncated: terminals.length > limit
|
||||
topologyRevisions: Object.fromEntries(
|
||||
[...new Set(matchingTerminals.map((terminal) => terminal.worktreeId))].map((worktreeId) => [
|
||||
worktreeId,
|
||||
this.getTerminalTopologyRevision(worktreeId)
|
||||
])
|
||||
),
|
||||
totalCount: matchingTerminals.length,
|
||||
truncated: matchingTerminals.length > limit
|
||||
}
|
||||
}
|
||||
|
||||
private getTerminalTopologyRevision(worktreeId: string): number {
|
||||
const repoId = getRepoIdFromWorktreeId(worktreeId)
|
||||
return (
|
||||
this.store?.getWorkspaceSession?.()?.terminalTopologyRevisionByRepoId?.[repoId] ??
|
||||
this.terminalTopologyRevisionByRepoId.get(repoId) ??
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
async adoptTerminalOrphans(
|
||||
request: RuntimeTerminalOrphanAdoptionRequest
|
||||
): Promise<RuntimeTerminalOrphanAdoptionResult> {
|
||||
if (request.claims.length === 0) {
|
||||
throw new Error('terminal_orphan_claims_required')
|
||||
}
|
||||
const worktree = await this.resolveWorktreeSelector(request.worktree)
|
||||
const livePtyIds = await this.refreshPtyWorktreeRecordsFromController([worktree], worktree.id)
|
||||
if (!livePtyIds) {
|
||||
throw new Error('terminal_liveness_unavailable')
|
||||
}
|
||||
const store = this.store
|
||||
const session = store?.getWorkspaceSession?.()
|
||||
if (!store?.setWorkspaceSession || !store.flushOrThrow || !session) {
|
||||
throw new Error('workspace_session_unavailable')
|
||||
}
|
||||
const sessionWorktreeId = resolveTerminalSessionWorktreeId(session, worktree.id)
|
||||
if (!sessionWorktreeId) {
|
||||
throw new Error('terminal_orphan_competing_owner')
|
||||
}
|
||||
const repoId = getRepoIdFromWorktreeId(worktree.id)
|
||||
const worktreeRepo = store.getRepo(repoId)
|
||||
if (!worktreeRepo) {
|
||||
throw new Error('terminal_orphan_owner_mismatch')
|
||||
}
|
||||
const worktreeConnectionId = worktreeRepo.connectionId ?? null
|
||||
let worktreeWslDistro: string | null = null
|
||||
if (!worktreeConnectionId) {
|
||||
try {
|
||||
worktreeWslDistro =
|
||||
getLocalProjectWorktreeGitOptions(this.requireStore(), worktreeRepo).wslDistro ?? null
|
||||
} catch {
|
||||
throw new Error('terminal_orphan_owner_mismatch')
|
||||
}
|
||||
}
|
||||
const currentRevision = this.getTerminalTopologyRevision(worktree.id)
|
||||
const seenPtyIds = new Set<string>()
|
||||
const seenPaneKeys = new Set<string>()
|
||||
const validated = request.claims.map((claim) => {
|
||||
const paneKey = makePaneKey(claim.tabId, claim.leafId)
|
||||
if (seenPtyIds.has(claim.ptyId) || seenPaneKeys.has(paneKey)) {
|
||||
throw new Error('terminal_orphan_claim_duplicate')
|
||||
}
|
||||
seenPtyIds.add(claim.ptyId)
|
||||
seenPaneKeys.add(paneKey)
|
||||
const live = this.getLivePtyForHandle(claim.terminal)
|
||||
const pty = live?.pty
|
||||
const controllerIdentity = this.controllerTerminalIdentityByPtyId.get(claim.ptyId)
|
||||
if (
|
||||
!pty ||
|
||||
pty.ptyId !== claim.ptyId ||
|
||||
controllerIdentity?.handle !== claim.terminal ||
|
||||
controllerIdentity?.incarnationId !== claim.incarnationId ||
|
||||
!livePtyIds.has(claim.ptyId) ||
|
||||
!pty.connected ||
|
||||
!pty.incarnationId ||
|
||||
pty.incarnationId !== claim.incarnationId
|
||||
) {
|
||||
throw new Error('terminal_orphan_stale')
|
||||
}
|
||||
if (
|
||||
!runtimeWorktreeIdsEqual(pty.worktreeId, worktree.id) ||
|
||||
!terminalOrphanExecutionOwnersEqual(
|
||||
{ connectionId: worktreeConnectionId, wslDistro: worktreeWslDistro },
|
||||
{
|
||||
connectionId: pty.connectionId ?? null,
|
||||
...(controllerIdentity?.wslDistro !== undefined
|
||||
? { wslDistro: controllerIdentity.wslDistro }
|
||||
: process.platform === 'win32' && !worktreeConnectionId
|
||||
? {}
|
||||
: { wslDistro: null })
|
||||
}
|
||||
)
|
||||
) {
|
||||
throw new Error('terminal_orphan_owner_mismatch')
|
||||
}
|
||||
const visualOwners = this.getLeavesForPty(claim.ptyId)
|
||||
if (
|
||||
visualOwners.some(
|
||||
(owner) =>
|
||||
!runtimeWorktreeIdsEqual(owner.worktreeId, worktree.id) ||
|
||||
owner.tabId !== claim.tabId ||
|
||||
owner.leafId !== claim.leafId
|
||||
)
|
||||
) {
|
||||
throw new Error('terminal_orphan_already_visual')
|
||||
}
|
||||
if ((pty.tabId && pty.tabId !== claim.tabId) || (pty.paneKey && pty.paneKey !== paneKey)) {
|
||||
throw new Error('terminal_orphan_competing_owner')
|
||||
}
|
||||
return { claim, pty, paneKey }
|
||||
})
|
||||
|
||||
const persistedBindingsByPtyId = new Map<string, { worktreeId: string; paneKey: string }[]>()
|
||||
const addPersistedBinding = (
|
||||
ptyId: string,
|
||||
binding: { worktreeId: string; paneKey: string }
|
||||
): void => {
|
||||
const bindings = persistedBindingsByPtyId.get(ptyId) ?? []
|
||||
bindings.push(binding)
|
||||
persistedBindingsByPtyId.set(ptyId, bindings)
|
||||
}
|
||||
for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree)) {
|
||||
for (const tab of tabs) {
|
||||
const layout = session.terminalLayoutsByTabId[tab.id]
|
||||
for (const [leafId, boundPtyId] of Object.entries(layout?.ptyIdsByLeafId ?? {})) {
|
||||
if (boundPtyId) {
|
||||
addPersistedBinding(boundPtyId, {
|
||||
worktreeId,
|
||||
paneKey: makePaneKey(tab.id, leafId)
|
||||
})
|
||||
}
|
||||
}
|
||||
if (tab.ptyId && !layout) {
|
||||
addPersistedBinding(tab.ptyId, { worktreeId, paneKey: tab.id })
|
||||
}
|
||||
}
|
||||
}
|
||||
const persistedBinding = (ptyId: string): { worktreeId: string; paneKey: string } | null => {
|
||||
const bindings = persistedBindingsByPtyId.get(ptyId) ?? []
|
||||
if (bindings.length > 1) {
|
||||
throw new Error('terminal_orphan_competing_owner')
|
||||
}
|
||||
return bindings[0] ?? null
|
||||
}
|
||||
const isExactPersisted = validated.every(({ claim, paneKey }) => {
|
||||
const binding = persistedBinding(claim.ptyId)
|
||||
return (
|
||||
binding !== null &&
|
||||
runtimeWorktreeIdsEqual(binding.worktreeId, worktree.id) &&
|
||||
binding.paneKey === paneKey &&
|
||||
session.terminalPtyIncarnationsByPaneKey?.[paneKey] === claim.incarnationId
|
||||
)
|
||||
})
|
||||
if (isExactPersisted && sessionWorktreeId === worktree.id) {
|
||||
return {
|
||||
adopted: false,
|
||||
topologyRevision: currentRevision,
|
||||
snapshot: await this.listMobileSessionTabs(`id:${worktree.id}`)
|
||||
}
|
||||
}
|
||||
if (currentRevision !== request.expectedTopologyRevision) {
|
||||
throw new Error('terminal_topology_conflict')
|
||||
}
|
||||
|
||||
const topologyTabsById = new Map(request.topology?.tabs.map((tab) => [tab.tabId, tab]) ?? [])
|
||||
const topologyGroups = request.topology?.groups ?? []
|
||||
if (request.topology) {
|
||||
const claimedLeafIdsByTabId = new Map<string, Set<string>>()
|
||||
for (const { claim } of validated) {
|
||||
const leafIds = claimedLeafIdsByTabId.get(claim.tabId) ?? new Set<string>()
|
||||
leafIds.add(claim.leafId)
|
||||
claimedLeafIdsByTabId.set(claim.tabId, leafIds)
|
||||
}
|
||||
if (
|
||||
topologyTabsById.size !== request.topology.tabs.length ||
|
||||
topologyTabsById.size !== claimedLeafIdsByTabId.size
|
||||
) {
|
||||
throw new Error('terminal_orphan_topology_invalid')
|
||||
}
|
||||
for (const [tabId, claimedLeafIds] of claimedLeafIdsByTabId) {
|
||||
const topologyTab = topologyTabsById.get(tabId)
|
||||
if (!topologyTab) {
|
||||
throw new Error('terminal_orphan_topology_invalid')
|
||||
}
|
||||
const topologyLeafIds = new Set<string>()
|
||||
const nodes = [topologyTab.root]
|
||||
let leafCount = 0
|
||||
while (nodes.length > 0) {
|
||||
const node = nodes.pop()!
|
||||
if (node.type === 'leaf') {
|
||||
leafCount += 1
|
||||
topologyLeafIds.add(node.leafId)
|
||||
} else {
|
||||
nodes.push(node.first, node.second)
|
||||
}
|
||||
}
|
||||
if (
|
||||
leafCount !== topologyLeafIds.size ||
|
||||
topologyLeafIds.size !== claimedLeafIds.size ||
|
||||
[...topologyLeafIds].some((leafId) => !claimedLeafIds.has(leafId)) ||
|
||||
!topologyLeafIds.has(topologyTab.activeLeafId) ||
|
||||
(topologyTab.expandedLeafId !== null && !topologyLeafIds.has(topologyTab.expandedLeafId))
|
||||
) {
|
||||
throw new Error('terminal_orphan_topology_invalid')
|
||||
}
|
||||
}
|
||||
const seenGroupIds = new Set<string>()
|
||||
const groupedTabIds = new Set<string>()
|
||||
for (const group of topologyGroups) {
|
||||
if (seenGroupIds.has(group.id) || !group.tabOrder.includes(group.activeTabId)) {
|
||||
throw new Error('terminal_orphan_topology_invalid')
|
||||
}
|
||||
seenGroupIds.add(group.id)
|
||||
for (const tabId of group.tabOrder) {
|
||||
if (!topologyTabsById.has(tabId) || groupedTabIds.has(tabId)) {
|
||||
throw new Error('terminal_orphan_topology_invalid')
|
||||
}
|
||||
groupedTabIds.add(tabId)
|
||||
}
|
||||
if (group.recentTabIds?.some((tabId) => !group.tabOrder.includes(tabId))) {
|
||||
throw new Error('terminal_orphan_topology_invalid')
|
||||
}
|
||||
}
|
||||
if (groupedTabIds.size !== topologyTabsById.size) {
|
||||
throw new Error('terminal_orphan_topology_invalid')
|
||||
}
|
||||
if (request.topology.groupLayout) {
|
||||
if (!hasExactTerminalOrphanGroupLayout(request.topology.groupLayout, seenGroupIds)) {
|
||||
throw new Error('terminal_orphan_topology_invalid')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const { claim, paneKey } of validated) {
|
||||
const existingBinding = persistedBinding(claim.ptyId)
|
||||
if (
|
||||
existingBinding &&
|
||||
(!runtimeWorktreeIdsEqual(existingBinding.worktreeId, worktree.id) ||
|
||||
existingBinding.paneKey !== paneKey)
|
||||
) {
|
||||
throw new Error('terminal_orphan_competing_owner')
|
||||
}
|
||||
const proposedPtyId =
|
||||
session.terminalLayoutsByTabId[claim.tabId]?.ptyIdsByLeafId?.[claim.leafId]
|
||||
if (proposedPtyId && proposedPtyId !== claim.ptyId) {
|
||||
throw new Error('terminal_orphan_surface_occupied')
|
||||
}
|
||||
const graphOwner = this.leaves.get(this.getLeafKey(claim.tabId, claim.leafId))
|
||||
if (
|
||||
graphOwner &&
|
||||
(graphOwner.ptyId !== claim.ptyId ||
|
||||
!runtimeWorktreeIdsEqual(graphOwner.worktreeId, worktree.id))
|
||||
) {
|
||||
throw new Error('terminal_orphan_surface_occupied')
|
||||
}
|
||||
if (
|
||||
Object.entries(session.tabsByWorktree).some(
|
||||
([ownerWorktreeId, tabs]) =>
|
||||
!runtimeWorktreeIdsEqual(ownerWorktreeId, worktree.id) &&
|
||||
tabs.some((tab) => tab.id === claim.tabId)
|
||||
)
|
||||
) {
|
||||
throw new Error('terminal_orphan_surface_occupied')
|
||||
}
|
||||
if (session.terminalSurfaceTombstonesByPaneKey?.[paneKey]) {
|
||||
throw new Error('terminal_orphan_surface_retired')
|
||||
}
|
||||
for (const snapshot of this.mobileSessionTabsByWorktree.values()) {
|
||||
const surfaceOwner = snapshot.tabs.find(
|
||||
(tab): tab is RuntimeMobileSessionTerminalTab =>
|
||||
tab.type === 'terminal' &&
|
||||
tab.parentTabId === claim.tabId &&
|
||||
tab.leafId === claim.leafId
|
||||
)
|
||||
if (
|
||||
surfaceOwner &&
|
||||
(snapshot.worktree !== worktree.id || surfaceOwner.ptyId !== claim.ptyId)
|
||||
) {
|
||||
throw new Error('terminal_orphan_surface_occupied')
|
||||
}
|
||||
const owner = snapshot.tabs.find(
|
||||
(tab): tab is RuntimeMobileSessionTerminalTab =>
|
||||
tab.type === 'terminal' && tab.ptyId === claim.ptyId
|
||||
)
|
||||
if (
|
||||
owner &&
|
||||
(snapshot.worktree !== worktree.id ||
|
||||
owner.parentTabId !== claim.tabId ||
|
||||
owner.leafId !== claim.leafId)
|
||||
) {
|
||||
throw new Error('terminal_orphan_competing_owner')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const next = structuredClone(session)
|
||||
canonicalizeTerminalSessionWorktreeId(next, sessionWorktreeId, worktree.id)
|
||||
const existingTabs = next.tabsByWorktree[worktree.id] ?? []
|
||||
const tabsById = new Map(existingTabs.map((tab) => [tab.id, tab]))
|
||||
for (const { claim, pty, paneKey } of validated) {
|
||||
let tab = tabsById.get(claim.tabId)
|
||||
if (!tab) {
|
||||
const title =
|
||||
getLatestPtyTitle(pty) ?? pty.controllerTitle ?? `Terminal ${tabsById.size + 1}`
|
||||
tab = {
|
||||
id: claim.tabId,
|
||||
ptyId: claim.ptyId,
|
||||
worktreeId: worktree.id,
|
||||
title,
|
||||
defaultTitle: title,
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: tabsById.size,
|
||||
createdAt: Date.now(),
|
||||
pendingActivationSpawn: true
|
||||
}
|
||||
tabsById.set(claim.tabId, tab)
|
||||
}
|
||||
const existingLayout = next.terminalLayoutsByTabId[claim.tabId]
|
||||
const topologyTab = topologyTabsById.get(claim.tabId)
|
||||
next.terminalLayoutsByTabId[claim.tabId] = topologyTab
|
||||
? {
|
||||
...existingLayout,
|
||||
root: topologyTab.root,
|
||||
activeLeafId: topologyTab.activeLeafId,
|
||||
expandedLeafId: topologyTab.expandedLeafId,
|
||||
ptyIdsByLeafId: {
|
||||
...existingLayout?.ptyIdsByLeafId,
|
||||
[claim.leafId]: claim.ptyId
|
||||
}
|
||||
}
|
||||
: existingLayout
|
||||
? {
|
||||
...existingLayout,
|
||||
root: this.collectPersistedTerminalLeafIds(existingLayout).includes(claim.leafId)
|
||||
? existingLayout.root
|
||||
: existingLayout.root === null
|
||||
? { type: 'leaf', leafId: claim.leafId }
|
||||
: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
first: existingLayout.root,
|
||||
second: { type: 'leaf', leafId: claim.leafId }
|
||||
},
|
||||
ptyIdsByLeafId: {
|
||||
...existingLayout.ptyIdsByLeafId,
|
||||
[claim.leafId]: claim.ptyId
|
||||
}
|
||||
}
|
||||
: {
|
||||
root: { type: 'leaf', leafId: claim.leafId },
|
||||
activeLeafId: claim.leafId,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [claim.leafId]: claim.ptyId }
|
||||
}
|
||||
next.terminalPtyIncarnationsByPaneKey = {
|
||||
...next.terminalPtyIncarnationsByPaneKey,
|
||||
[paneKey]: claim.incarnationId
|
||||
}
|
||||
}
|
||||
const adoptedTabIds = [...new Set(validated.map(({ claim }) => claim.tabId))]
|
||||
next.tabsByWorktree[worktree.id] = [...tabsById.values()]
|
||||
const activeTabId =
|
||||
request.activeTabId && tabsById.has(request.activeTabId)
|
||||
? request.activeTabId
|
||||
: (adoptedTabIds[0] ?? null)
|
||||
const existingGroups = next.tabGroups?.[worktree.id] ?? []
|
||||
const targetGroupId =
|
||||
(request.activeGroupId && existingGroups.some((group) => group.id === request.activeGroupId)
|
||||
? request.activeGroupId
|
||||
: existingGroups[0]?.id) ??
|
||||
request.activeGroupId ??
|
||||
randomUUID()
|
||||
const proposedGroups = topologyGroups.map((group) => ({
|
||||
...group,
|
||||
worktreeId: worktree.id
|
||||
}))
|
||||
const groups =
|
||||
existingGroups.length === 0 && proposedGroups.length > 0
|
||||
? proposedGroups
|
||||
: existingGroups.length > 0
|
||||
? existingGroups
|
||||
.map((group) => {
|
||||
const proposed = proposedGroups.find((candidate) => candidate.id === group.id)
|
||||
const tabOrder = proposed
|
||||
? [
|
||||
...group.tabOrder.filter((tabId) => !adoptedTabIds.includes(tabId)),
|
||||
...proposed.tabOrder
|
||||
]
|
||||
: group.id === targetGroupId && proposedGroups.length === 0
|
||||
? [...new Set([...group.tabOrder, ...adoptedTabIds])]
|
||||
: group.tabOrder.filter((tabId) => !adoptedTabIds.includes(tabId))
|
||||
return {
|
||||
...group,
|
||||
tabOrder,
|
||||
activeTabId: proposed
|
||||
? proposed.activeTabId
|
||||
: group.id === targetGroupId && activeTabId
|
||||
? activeTabId
|
||||
: group.activeTabId && tabOrder.includes(group.activeTabId)
|
||||
? group.activeTabId
|
||||
: (tabOrder[0] ?? null),
|
||||
...(proposed?.recentTabIds ? { recentTabIds: proposed.recentTabIds } : {})
|
||||
}
|
||||
})
|
||||
.concat(
|
||||
proposedGroups.filter(
|
||||
(proposed) => !existingGroups.some((group) => group.id === proposed.id)
|
||||
)
|
||||
)
|
||||
: [{ id: targetGroupId, worktreeId: worktree.id, activeTabId, tabOrder: adoptedTabIds }]
|
||||
const retainedGroups = groups.filter((group) => group.tabOrder.length > 0)
|
||||
next.tabGroups = {
|
||||
...next.tabGroups,
|
||||
[worktree.id]: retainedGroups
|
||||
}
|
||||
const mergedGroupLayout = mergeTerminalOrphanGroupLayout({
|
||||
existingLayout: next.tabGroupLayouts?.[worktree.id],
|
||||
existingGroupIds: existingGroups.map((group) => group.id),
|
||||
proposedLayout: request.topology?.groupLayout,
|
||||
proposedGroupIds: proposedGroups.map((group) => group.id),
|
||||
mergedGroupIds: retainedGroups.map((group) => group.id)
|
||||
})
|
||||
if (mergedGroupLayout) {
|
||||
next.tabGroupLayouts = {
|
||||
...next.tabGroupLayouts,
|
||||
[worktree.id]: mergedGroupLayout
|
||||
}
|
||||
}
|
||||
const activeGroup =
|
||||
(request.activeGroupId
|
||||
? retainedGroups.find(
|
||||
(group) =>
|
||||
group.id === request.activeGroupId &&
|
||||
(!activeTabId || group.tabOrder.includes(activeTabId))
|
||||
)
|
||||
: undefined) ??
|
||||
retainedGroups.find((group) => activeTabId && group.tabOrder.includes(activeTabId)) ??
|
||||
retainedGroups[0]!
|
||||
const convergedActiveTabId =
|
||||
activeTabId && activeGroup.tabOrder.includes(activeTabId)
|
||||
? activeTabId
|
||||
: activeGroup.activeTabId
|
||||
next.activeTabIdByWorktree = {
|
||||
...next.activeTabIdByWorktree,
|
||||
...(convergedActiveTabId ? { [worktree.id]: convergedActiveTabId } : {})
|
||||
}
|
||||
next.activeGroupIdByWorktree = {
|
||||
...next.activeGroupIdByWorktree,
|
||||
[worktree.id]: activeGroup.id
|
||||
}
|
||||
const persisted = advanceTerminalTopologyRevision(next, worktree.id)
|
||||
try {
|
||||
store.setWorkspaceSession(persisted)
|
||||
store.flushOrThrow()
|
||||
} catch (error) {
|
||||
store.setWorkspaceSession(session)
|
||||
throw error
|
||||
}
|
||||
for (const { claim, pty, paneKey } of validated) {
|
||||
pty.tabId = claim.tabId
|
||||
pty.paneKey = paneKey
|
||||
}
|
||||
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktree.id, {
|
||||
force: true,
|
||||
allowAttachedWindow: true,
|
||||
onlyServeOwnedTerminals: true
|
||||
})
|
||||
this.notifyMobileSessionTabsChanged(worktree.id)
|
||||
return {
|
||||
adopted: true,
|
||||
topologyRevision: persisted.terminalTopologyRevisionByRepoId?.[repoId] ?? currentRevision + 1,
|
||||
snapshot: await this.listMobileSessionTabs(`id:${worktree.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12643,7 +13166,10 @@ export class OrcaRuntimeService {
|
||||
return {
|
||||
type: 'group',
|
||||
groupId: group.id,
|
||||
activeTabId: group.activeTabId,
|
||||
activeTabId:
|
||||
group.activeTabId && tabs.some((tab) => tab.tabId === group.activeTabId)
|
||||
? group.activeTabId
|
||||
: (tabs[0]?.tabId ?? null),
|
||||
tabs
|
||||
}
|
||||
})
|
||||
@@ -24394,6 +24920,7 @@ export class OrcaRuntimeService {
|
||||
lastOscTitleAt: null,
|
||||
managementTitle: null,
|
||||
managementTitleAt: null,
|
||||
controllerTitle: null,
|
||||
title: state.title ?? null,
|
||||
titleUpdatedAt: titleObservedAt,
|
||||
lastOutputAt: state.lastOutputAt ?? null,
|
||||
@@ -24519,13 +25046,56 @@ export class OrcaRuntimeService {
|
||||
return null
|
||||
}
|
||||
const sessions = sessionsResult.value
|
||||
const controllerIdentityByPtyId = new Map<
|
||||
string,
|
||||
{ handle: string; incarnationId: string; wslDistro?: string | null }
|
||||
>()
|
||||
const ptyIdByControllerHandle = new Map<string, string>()
|
||||
const ambiguousControllerPtyIds = new Set<string>()
|
||||
for (const session of sessions) {
|
||||
const handle = session.terminalHandle?.trim()
|
||||
const incarnationId = session.incarnationId?.trim()
|
||||
if (!handle?.startsWith('term_') || !incarnationId) {
|
||||
continue
|
||||
}
|
||||
const priorPtyId = ptyIdByControllerHandle.get(handle)
|
||||
if (priorPtyId && priorPtyId !== session.id) {
|
||||
ambiguousControllerPtyIds.add(priorPtyId)
|
||||
ambiguousControllerPtyIds.add(session.id)
|
||||
controllerIdentityByPtyId.delete(priorPtyId)
|
||||
continue
|
||||
}
|
||||
if (controllerIdentityByPtyId.has(session.id)) {
|
||||
ambiguousControllerPtyIds.add(session.id)
|
||||
controllerIdentityByPtyId.delete(session.id)
|
||||
continue
|
||||
}
|
||||
ptyIdByControllerHandle.set(handle, session.id)
|
||||
controllerIdentityByPtyId.set(session.id, {
|
||||
handle,
|
||||
incarnationId,
|
||||
...(session.wslDistro !== undefined ? { wslDistro: session.wslDistro } : {})
|
||||
})
|
||||
}
|
||||
for (const ptyId of ambiguousControllerPtyIds) {
|
||||
controllerIdentityByPtyId.delete(ptyId)
|
||||
}
|
||||
this.controllerTerminalIdentityByPtyId = controllerIdentityByPtyId
|
||||
const persistedWorktreeIdByPtyId = indexPersistedPtyWorktreeBindings(
|
||||
this.store?.getWorkspaceSession?.()
|
||||
)
|
||||
const persistedSurfaceByPtyId = indexPersistedPtySurfaceBindings(
|
||||
this.store?.getWorkspaceSession?.()
|
||||
)
|
||||
const allLivePtyIds = new Set(sessions.map((session) => session.id))
|
||||
const selectedLivePtyIds = new Set<string>()
|
||||
for (const session of sessions) {
|
||||
this.adoptControllerTerminalHandle(session.id, session.terminalHandle)
|
||||
const controllerIdentity = controllerIdentityByPtyId.get(session.id)
|
||||
this.adoptControllerTerminalHandle(
|
||||
session.id,
|
||||
controllerIdentity?.handle ?? session.terminalHandle,
|
||||
controllerIdentity?.incarnationId ?? session.incarnationId
|
||||
)
|
||||
const persistedWorktreeId = persistedWorktreeIdByPtyId.get(session.id)
|
||||
const providerWorktree = resolvedWorktrees.find(
|
||||
(worktree) => session.worktreeId && runtimeWorktreeIdsEqual(worktree.id, session.worktreeId)
|
||||
@@ -24564,10 +25134,23 @@ export class OrcaRuntimeService {
|
||||
continue
|
||||
}
|
||||
if (worktreeId) {
|
||||
this.recordPtyWorktree(session.id, worktreeId, {
|
||||
const persistedSurface = persistedSurfaceByPtyId.get(session.id)
|
||||
const restoresExactSurface =
|
||||
persistedSurface &&
|
||||
session.incarnationId &&
|
||||
persistedSurface.incarnationId === session.incarnationId &&
|
||||
runtimeWorktreeIdsEqual(persistedSurface.worktreeId, worktreeId)
|
||||
const pty = this.recordPtyWorktree(session.id, worktreeId, {
|
||||
connected: true,
|
||||
...(session.incarnationId ? { incarnationId: session.incarnationId } : {})
|
||||
...(session.incarnationId ? { incarnationId: session.incarnationId } : {}),
|
||||
...(session.wslDistro !== undefined
|
||||
? { isWsl: Boolean(session.wslDistro), wslDistro: session.wslDistro }
|
||||
: {}),
|
||||
...(restoresExactSurface
|
||||
? { tabId: persistedSurface.tabId, paneKey: persistedSurface.paneKey }
|
||||
: {})
|
||||
})
|
||||
pty.controllerTitle = session.title?.trim() || null
|
||||
}
|
||||
// Why: fire-and-forget so this listing hot path doesn't serialize a relay round-trip per session and a throw can't abort the sweep below.
|
||||
this.refreshPtyForegroundAgent(session.id)
|
||||
@@ -24805,9 +25388,12 @@ export class OrcaRuntimeService {
|
||||
const worktree = worktreesById.get(leaf.worktreeId)
|
||||
const tab = this.tabs.get(leaf.tabId) ?? null
|
||||
|
||||
const pty = leaf.ptyId ? this.ptysById.get(leaf.ptyId) : undefined
|
||||
return {
|
||||
handle: this.issueHandle(leaf),
|
||||
ptyId: leaf.ptyId,
|
||||
incarnationId: pty?.incarnationId ?? null,
|
||||
orphaned: false,
|
||||
worktreeId: leaf.worktreeId,
|
||||
worktreePath: worktree?.path ?? '',
|
||||
branch: worktree?.branch ?? '',
|
||||
@@ -26155,14 +26741,18 @@ export class OrcaRuntimeService {
|
||||
): RuntimeTerminalSummary {
|
||||
const worktree = worktreesById.get(pty.worktreeId)
|
||||
|
||||
const pane = parsePaneKey(pty.paneKey ?? '')
|
||||
const orphaned = !pty.tabId || !pane || pane.tabId !== pty.tabId
|
||||
return {
|
||||
handle: this.issuePtyHandle(pty),
|
||||
ptyId: pty.ptyId,
|
||||
incarnationId: pty.incarnationId,
|
||||
orphaned,
|
||||
worktreeId: pty.worktreeId,
|
||||
worktreePath: worktree?.path ?? '',
|
||||
branch: worktree?.branch ?? '',
|
||||
tabId: `pty:${pty.ptyId}`,
|
||||
leafId: `pty:${pty.ptyId}`,
|
||||
tabId: orphaned ? `pty:${pty.ptyId}` : pty.tabId!,
|
||||
leafId: orphaned ? `pty:${pty.ptyId}` : pane.leafId,
|
||||
title: getLatestPtyTitle(pty),
|
||||
connected: pty.connected,
|
||||
writable: pty.connected,
|
||||
@@ -31425,6 +32015,59 @@ function runtimeWorktreeIdentityKey(worktreeId: string): string {
|
||||
: worktreeId
|
||||
}
|
||||
|
||||
function resolveTerminalSessionWorktreeId(
|
||||
session: WorkspaceSessionState,
|
||||
targetWorktreeId: string
|
||||
): string | null {
|
||||
const keyedWorktreeIds = new Set([
|
||||
...Object.keys(session.tabsByWorktree),
|
||||
...Object.keys(session.tabGroups ?? {}),
|
||||
...Object.keys(session.tabGroupLayouts ?? {}),
|
||||
...Object.keys(session.activeTabIdByWorktree ?? {}),
|
||||
...Object.keys(session.activeGroupIdByWorktree ?? {})
|
||||
])
|
||||
const matches = [...keyedWorktreeIds].filter((worktreeId) =>
|
||||
runtimeWorktreeIdsEqual(worktreeId, targetWorktreeId)
|
||||
)
|
||||
return matches.length > 1 ? null : (matches[0] ?? targetWorktreeId)
|
||||
}
|
||||
|
||||
function canonicalizeTerminalSessionWorktreeId(
|
||||
session: WorkspaceSessionState,
|
||||
sourceWorktreeId: string,
|
||||
targetWorktreeId: string
|
||||
): void {
|
||||
if (sourceWorktreeId === targetWorktreeId) {
|
||||
return
|
||||
}
|
||||
const tabs = session.tabsByWorktree[sourceWorktreeId] ?? []
|
||||
delete session.tabsByWorktree[sourceWorktreeId]
|
||||
session.tabsByWorktree[targetWorktreeId] = tabs.map((tab) => ({
|
||||
...tab,
|
||||
worktreeId: targetWorktreeId
|
||||
}))
|
||||
|
||||
const groups = session.tabGroups?.[sourceWorktreeId]
|
||||
if (groups) {
|
||||
delete session.tabGroups![sourceWorktreeId]
|
||||
session.tabGroups![targetWorktreeId] = groups.map((group) => ({
|
||||
...group,
|
||||
worktreeId: targetWorktreeId
|
||||
}))
|
||||
}
|
||||
for (const keyedState of [
|
||||
session.tabGroupLayouts,
|
||||
session.activeTabIdByWorktree,
|
||||
session.activeGroupIdByWorktree
|
||||
]) {
|
||||
if (!keyedState || !Object.hasOwn(keyedState, sourceWorktreeId)) {
|
||||
continue
|
||||
}
|
||||
keyedState[targetWorktreeId] = keyedState[sourceWorktreeId] as never
|
||||
delete keyedState[sourceWorktreeId]
|
||||
}
|
||||
}
|
||||
|
||||
function inferWorktreeIdFromPtyId(ptyId: string): string | null {
|
||||
return parsePtySessionId(ptyId).worktreeId
|
||||
}
|
||||
@@ -31461,6 +32104,49 @@ function indexPersistedPtyWorktreeBindings(
|
||||
return worktreeIdByPtyId
|
||||
}
|
||||
|
||||
function indexPersistedPtySurfaceBindings(
|
||||
session: WorkspaceSessionState | null | undefined
|
||||
): ReadonlyMap<
|
||||
string,
|
||||
{ worktreeId: string; tabId: string; paneKey: string; incarnationId: string }
|
||||
> {
|
||||
const bindingByPtyId = new Map<
|
||||
string,
|
||||
{ worktreeId: string; tabId: string; paneKey: string; incarnationId: string }
|
||||
>()
|
||||
const ambiguousPtyIds = new Set<string>()
|
||||
for (const [worktreeId, tabs] of Object.entries(session?.tabsByWorktree ?? {})) {
|
||||
for (const tab of tabs) {
|
||||
for (const [leafId, ptyId] of Object.entries(
|
||||
session?.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {}
|
||||
)) {
|
||||
if (!ptyId || ambiguousPtyIds.has(ptyId)) {
|
||||
continue
|
||||
}
|
||||
const paneKey = makePaneKey(tab.id, leafId)
|
||||
const incarnationId = session?.terminalPtyIncarnationsByPaneKey?.[paneKey]
|
||||
if (!incarnationId) {
|
||||
continue
|
||||
}
|
||||
const binding = { worktreeId, tabId: tab.id, paneKey, incarnationId }
|
||||
const existing = bindingByPtyId.get(ptyId)
|
||||
if (
|
||||
existing &&
|
||||
(existing.worktreeId !== worktreeId ||
|
||||
existing.paneKey !== paneKey ||
|
||||
existing.incarnationId !== incarnationId)
|
||||
) {
|
||||
bindingByPtyId.delete(ptyId)
|
||||
ambiguousPtyIds.add(ptyId)
|
||||
continue
|
||||
}
|
||||
bindingByPtyId.set(ptyId, binding)
|
||||
}
|
||||
}
|
||||
}
|
||||
return bindingByPtyId
|
||||
}
|
||||
|
||||
function setsEqual<T>(a: ReadonlySet<T>, b: ReadonlySet<T>): boolean {
|
||||
if (a.size !== b.size) {
|
||||
return false
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AUTOMATION_METHODS } from './automations'
|
||||
import { REPO_METHODS } from './repo'
|
||||
import { WORKTREE_METHODS } from './worktree'
|
||||
import { TERMINAL_METHODS } from './terminal'
|
||||
import { TERMINAL_ORPHAN_METHODS } from './terminal-orphan'
|
||||
import { BROWSER_CORE_METHODS } from './browser-core'
|
||||
import { BROWSER_EXTRA_METHODS } from './browser-extras'
|
||||
import { BROWSER_SCREENCAST_METHODS } from './browser-screencast'
|
||||
@@ -49,6 +50,7 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
|
||||
...WORKTREE_METHODS,
|
||||
...AGENT_SESSION_METHODS,
|
||||
...TERMINAL_METHODS,
|
||||
...TERMINAL_ORPHAN_METHODS,
|
||||
...BROWSER_CORE_METHODS,
|
||||
...BROWSER_SCREENCAST_METHODS,
|
||||
...BROWSER_EXTRA_METHODS,
|
||||
|
||||
@@ -81,7 +81,10 @@ function parseTerminalPaneLayoutNode(value: unknown): TerminalPaneLayoutNodeInpu
|
||||
}
|
||||
if (
|
||||
node.ratio !== undefined &&
|
||||
(typeof node.ratio !== 'number' || node.ratio < 0 || node.ratio > 1)
|
||||
(typeof node.ratio !== 'number' ||
|
||||
!Number.isFinite(node.ratio) ||
|
||||
node.ratio < 0 ||
|
||||
node.ratio > 1)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
@@ -93,7 +96,7 @@ function parseTerminalPaneLayoutNode(value: unknown): TerminalPaneLayoutNodeInpu
|
||||
return value as TerminalPaneLayoutNodeInput
|
||||
}
|
||||
|
||||
const TerminalPaneLayoutNodeSchema = z
|
||||
export const TerminalPaneLayoutNodeSchema = z
|
||||
.unknown()
|
||||
.transform((value) => parseTerminalPaneLayoutNode(value))
|
||||
.pipe(
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { z } from 'zod'
|
||||
import type { TabGroupLayoutNode } from '../../../../shared/types'
|
||||
import { defineMethod, type RpcAnyMethod } from '../core'
|
||||
import { OptionalString, requiredString } from '../schemas'
|
||||
import { TerminalPaneLayoutNodeSchema } from './session-tabs-schemas'
|
||||
|
||||
function parseOrphanGroupLayout(value: unknown): TabGroupLayoutNode | null {
|
||||
const stack: { value: unknown; depth: number }[] = [{ value, depth: 0 }]
|
||||
let count = 0
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop()!
|
||||
if (
|
||||
current.depth > 64 ||
|
||||
++count > 1_024 ||
|
||||
!current.value ||
|
||||
typeof current.value !== 'object'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const node = current.value as Record<string, unknown>
|
||||
if (node.type === 'leaf') {
|
||||
if (
|
||||
typeof node.groupId !== 'string' ||
|
||||
node.groupId.length < 1 ||
|
||||
node.groupId.length > 256
|
||||
) {
|
||||
return null
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (
|
||||
node.type !== 'split' ||
|
||||
(node.direction !== 'horizontal' && node.direction !== 'vertical') ||
|
||||
(node.ratio !== undefined &&
|
||||
(typeof node.ratio !== 'number' ||
|
||||
!Number.isFinite(node.ratio) ||
|
||||
node.ratio < 0 ||
|
||||
node.ratio > 1))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
stack.push(
|
||||
{ value: node.first, depth: current.depth + 1 },
|
||||
{ value: node.second, depth: current.depth + 1 }
|
||||
)
|
||||
}
|
||||
return value as TabGroupLayoutNode
|
||||
}
|
||||
|
||||
const TerminalOrphanGroupLayout = z
|
||||
.unknown()
|
||||
.transform(parseOrphanGroupLayout)
|
||||
.pipe(z.custom<TabGroupLayoutNode>((value) => value !== null, 'Invalid orphan group layout'))
|
||||
|
||||
const TerminalOrphanTopology = z.object({
|
||||
tabs: z
|
||||
.array(
|
||||
z.object({
|
||||
tabId: requiredString('Missing topology tab id').pipe(z.string().max(256)),
|
||||
root: TerminalPaneLayoutNodeSchema,
|
||||
activeLeafId: requiredString('Missing active leaf id').pipe(z.string().max(128)),
|
||||
expandedLeafId: z.string().max(128).nullable()
|
||||
})
|
||||
)
|
||||
.min(1)
|
||||
.max(64),
|
||||
groups: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().min(1).max(256),
|
||||
activeTabId: z.string().min(1).max(256),
|
||||
tabOrder: z.array(z.string().min(1).max(256)).min(1).max(64),
|
||||
recentTabIds: z.array(z.string().min(1).max(256)).max(64).optional()
|
||||
})
|
||||
)
|
||||
.min(1)
|
||||
.max(64),
|
||||
groupLayout: TerminalOrphanGroupLayout.optional()
|
||||
})
|
||||
|
||||
const TerminalAdoptOrphans = z.object({
|
||||
worktree: requiredString('Missing worktree selector').pipe(z.string().max(32_768)),
|
||||
expectedTopologyRevision: z.number().int().nonnegative(),
|
||||
claims: z
|
||||
.array(
|
||||
z.object({
|
||||
terminal: requiredString('Missing terminal handle').pipe(z.string().max(256)),
|
||||
ptyId: requiredString('Missing PTY id').pipe(z.string().max(8_192)),
|
||||
incarnationId: requiredString('Missing PTY incarnation').pipe(z.string().max(256)),
|
||||
tabId: requiredString('Missing tab id').pipe(z.string().max(256)),
|
||||
leafId: requiredString('Missing leaf id').pipe(z.string().max(128))
|
||||
})
|
||||
)
|
||||
.min(1)
|
||||
.max(64),
|
||||
activeTabId: OptionalString.pipe(z.string().max(256).optional()),
|
||||
activeGroupId: OptionalString.pipe(z.string().max(256).optional()),
|
||||
topology: TerminalOrphanTopology.optional()
|
||||
})
|
||||
|
||||
export const TERMINAL_ORPHAN_METHODS: RpcAnyMethod[] = [
|
||||
defineMethod({
|
||||
name: 'terminal.adoptOrphans',
|
||||
params: TerminalAdoptOrphans,
|
||||
handler: async (params, { runtime }) => runtime.adoptTerminalOrphans(params)
|
||||
})
|
||||
]
|
||||
@@ -831,6 +831,10 @@ const TerminalFocus = TerminalHandle.extend({
|
||||
const TerminalListParams = z.object({
|
||||
worktree: OptionalString,
|
||||
limit: OptionalFiniteNumber,
|
||||
handles: z
|
||||
.array(requiredString('Missing terminal handle').pipe(z.string().max(256)))
|
||||
.max(64)
|
||||
.optional(),
|
||||
requireFreshPtyLiveness: z.boolean().optional()
|
||||
})
|
||||
|
||||
@@ -1103,6 +1107,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
params: TerminalListParams,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.listTerminals(params.worktree, params.limit, {
|
||||
handles: params.handles,
|
||||
requireFreshPtyLiveness: params.requireFreshPtyLiveness
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Wait
|
||||
} from './methods/browser-schemas'
|
||||
import { TERMINAL_METHODS } from './methods/terminal'
|
||||
import { TERMINAL_ORPHAN_METHODS } from './methods/terminal-orphan'
|
||||
import { WORKTREE_METHODS } from './methods/worktree'
|
||||
|
||||
function expectParses(schema: ZodType, value: unknown): void {
|
||||
@@ -85,6 +86,85 @@ describe('RPC optional pipe schemas', () => {
|
||||
expectParses(methodParams(WORKTREE_METHODS, 'worktree.prefetchCreateBase'), { repo: 'repo-1' })
|
||||
})
|
||||
|
||||
it('requires complete, bounded orphan adoption claims and a topology revision', () => {
|
||||
const adopt = methodParams(TERMINAL_ORPHAN_METHODS, 'terminal.adoptOrphans')
|
||||
const claim = {
|
||||
terminal: 'term-live',
|
||||
ptyId: 'pty-live',
|
||||
incarnationId: 'inc-live',
|
||||
tabId: 'tab-live',
|
||||
leafId: 'leaf-live'
|
||||
}
|
||||
|
||||
expectParses(adopt, {
|
||||
worktree: 'id:repo::/worktree',
|
||||
expectedTopologyRevision: 4,
|
||||
claims: [claim],
|
||||
topology: {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-live',
|
||||
root: { type: 'leaf', leafId: 'leaf-live' },
|
||||
activeLeafId: 'leaf-live',
|
||||
expandedLeafId: null
|
||||
}
|
||||
],
|
||||
groups: [{ id: 'group-live', activeTabId: 'tab-live', tabOrder: ['tab-live'] }],
|
||||
groupLayout: { type: 'leaf', groupId: 'group-live' }
|
||||
}
|
||||
})
|
||||
expectRejects(adopt, {
|
||||
worktree: 'id:repo::/worktree',
|
||||
expectedTopologyRevision: -1,
|
||||
claims: [claim]
|
||||
})
|
||||
expectRejects(adopt, {
|
||||
worktree: 'id:repo::/worktree',
|
||||
expectedTopologyRevision: 4,
|
||||
claims: [{ ...claim, incarnationId: '' }]
|
||||
})
|
||||
expectRejects(adopt, {
|
||||
worktree: 'id:repo::/worktree',
|
||||
expectedTopologyRevision: 4,
|
||||
claims: []
|
||||
})
|
||||
expectRejects(adopt, {
|
||||
worktree: 'id:repo::/worktree',
|
||||
expectedTopologyRevision: 4,
|
||||
claims: [{ ...claim, terminal: 't'.repeat(257) }]
|
||||
})
|
||||
expectRejects(adopt, {
|
||||
worktree: 'id:repo::/worktree',
|
||||
expectedTopologyRevision: 4,
|
||||
claims: [claim],
|
||||
topology: {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-live',
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
ratio: Number.NaN,
|
||||
first: { type: 'leaf', leafId: 'leaf-live' },
|
||||
second: { type: 'leaf', leafId: 'leaf-other' }
|
||||
},
|
||||
activeLeafId: 'leaf-live',
|
||||
expandedLeafId: null
|
||||
}
|
||||
],
|
||||
groups: [{ id: 'group-live', activeTabId: 'tab-live', tabOrder: ['tab-live'] }]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('bounds targeted terminal listing used by orphan recovery', () => {
|
||||
const list = methodParams(TERMINAL_METHODS, 'terminal.list')
|
||||
|
||||
expectParses(list, { worktree: 'id:repo::/worktree', handles: ['term-live'] })
|
||||
expectRejects(list, { handles: [''] })
|
||||
expectRejects(list, { handles: Array.from({ length: 65 }, (_, index) => `term-${index}`) })
|
||||
})
|
||||
|
||||
it('accepts worktree.create payloads sent by the previous mobile protocol', () => {
|
||||
const create = methodParams(WORKTREE_METHODS, 'worktree.create')
|
||||
|
||||
|
||||
@@ -362,6 +362,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
|
||||
'terminal.ensureAgentSession',
|
||||
'terminal.focus',
|
||||
'terminal.agentStatus',
|
||||
'terminal.adoptOrphans',
|
||||
'terminal.getAutoRestoreFit',
|
||||
'terminal.isRunningAgent',
|
||||
'terminal.list',
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { terminalOrphanExecutionOwnersEqual } from './terminal-orphan-owner'
|
||||
|
||||
describe('terminal orphan execution owner', () => {
|
||||
it('requires exact SSH host ownership', () => {
|
||||
expect(
|
||||
terminalOrphanExecutionOwnersEqual(
|
||||
{ connectionId: 'ssh-a', wslDistro: null },
|
||||
{ connectionId: 'ssh-b', wslDistro: null }
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
terminalOrphanExecutionOwnersEqual(
|
||||
{ connectionId: 'ssh-a', wslDistro: null },
|
||||
{ connectionId: 'ssh-a' }
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('matches WSL distro case-insensitively but never crosses native or another distro', () => {
|
||||
const expected = { connectionId: null, wslDistro: 'Ubuntu' }
|
||||
expect(
|
||||
terminalOrphanExecutionOwnersEqual(expected, {
|
||||
connectionId: null,
|
||||
wslDistro: 'ubuntu'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
terminalOrphanExecutionOwnersEqual(expected, { connectionId: null, wslDistro: null })
|
||||
).toBe(false)
|
||||
expect(
|
||||
terminalOrphanExecutionOwnersEqual(expected, {
|
||||
connectionId: null,
|
||||
wslDistro: 'Debian'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(terminalOrphanExecutionOwnersEqual(expected, { connectionId: null })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
export type TerminalOrphanExecutionOwner = {
|
||||
connectionId: string | null
|
||||
wslDistro?: string | null
|
||||
}
|
||||
|
||||
function normalizeWslDistro(distro: string | null): string | null {
|
||||
const normalized = distro?.trim().toLowerCase() ?? ''
|
||||
return normalized || null
|
||||
}
|
||||
|
||||
export function terminalOrphanExecutionOwnersEqual(
|
||||
expected: TerminalOrphanExecutionOwner,
|
||||
actual: TerminalOrphanExecutionOwner
|
||||
): boolean {
|
||||
if (expected.connectionId !== actual.connectionId) {
|
||||
return false
|
||||
}
|
||||
if (expected.connectionId !== null) {
|
||||
return true
|
||||
}
|
||||
if (expected.wslDistro === undefined || actual.wslDistro === undefined) {
|
||||
return false
|
||||
}
|
||||
return normalizeWslDistro(expected.wslDistro) === normalizeWslDistro(actual.wslDistro)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
hasExactTerminalOrphanGroupLayout,
|
||||
mergeTerminalOrphanGroupLayout
|
||||
} from './terminal-orphan-topology'
|
||||
|
||||
describe('terminal orphan topology', () => {
|
||||
it('rejects duplicate and missing group leaves', () => {
|
||||
expect(
|
||||
hasExactTerminalOrphanGroupLayout(
|
||||
{
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', groupId: 'group-a' },
|
||||
second: { type: 'leaf', groupId: 'group-a' }
|
||||
},
|
||||
new Set(['group-a', 'group-b'])
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps current host layout while preserving an unrelated proposed subtree', () => {
|
||||
expect(
|
||||
mergeTerminalOrphanGroupLayout({
|
||||
existingLayout: { type: 'leaf', groupId: 'group-live' },
|
||||
existingGroupIds: ['group-live'],
|
||||
proposedLayout: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
ratio: 0.6,
|
||||
first: { type: 'leaf', groupId: 'group-old-left' },
|
||||
second: { type: 'leaf', groupId: 'group-old-right' }
|
||||
},
|
||||
proposedGroupIds: ['group-old-left', 'group-old-right'],
|
||||
mergedGroupIds: ['group-live', 'group-old-left', 'group-old-right']
|
||||
})
|
||||
).toEqual({
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
ratio: 0.6,
|
||||
first: { type: 'leaf', groupId: 'group-live' },
|
||||
second: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
ratio: 0.6,
|
||||
first: { type: 'leaf', groupId: 'group-old-left' },
|
||||
second: { type: 'leaf', groupId: 'group-old-right' }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('grafts proposed groups beside their one current anchor without duplicating it', () => {
|
||||
expect(
|
||||
mergeTerminalOrphanGroupLayout({
|
||||
existingLayout: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
first: { type: 'leaf', groupId: 'group-live' },
|
||||
second: { type: 'leaf', groupId: 'group-other' }
|
||||
},
|
||||
existingGroupIds: ['group-live', 'group-other'],
|
||||
proposedLayout: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
ratio: 0.7,
|
||||
first: { type: 'leaf', groupId: 'group-live' },
|
||||
second: { type: 'leaf', groupId: 'group-recovered' }
|
||||
},
|
||||
proposedGroupIds: ['group-live', 'group-recovered'],
|
||||
mergedGroupIds: ['group-live', 'group-other', 'group-recovered']
|
||||
})
|
||||
).toEqual({
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
first: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
ratio: 0.7,
|
||||
first: { type: 'leaf', groupId: 'group-live' },
|
||||
second: { type: 'leaf', groupId: 'group-recovered' }
|
||||
},
|
||||
second: { type: 'leaf', groupId: 'group-other' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { TabGroupLayoutNode } from '../../shared/types'
|
||||
|
||||
function collectLayoutGroupIds(node: TabGroupLayoutNode | null | undefined, ids: string[]): void {
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
if (node.type === 'leaf') {
|
||||
ids.push(node.groupId)
|
||||
return
|
||||
}
|
||||
collectLayoutGroupIds(node.first, ids)
|
||||
collectLayoutGroupIds(node.second, ids)
|
||||
}
|
||||
|
||||
function pruneLayout(
|
||||
node: TabGroupLayoutNode | null | undefined,
|
||||
retainedGroupIds: ReadonlySet<string>
|
||||
): TabGroupLayoutNode | null {
|
||||
if (!node) {
|
||||
return null
|
||||
}
|
||||
if (node.type === 'leaf') {
|
||||
return retainedGroupIds.has(node.groupId) ? node : null
|
||||
}
|
||||
const first = pruneLayout(node.first, retainedGroupIds)
|
||||
const second = pruneLayout(node.second, retainedGroupIds)
|
||||
if (!first) {
|
||||
return second
|
||||
}
|
||||
if (!second) {
|
||||
return first
|
||||
}
|
||||
return { ...node, first, second }
|
||||
}
|
||||
|
||||
function appendMissingGroups(
|
||||
layout: TabGroupLayoutNode | null,
|
||||
orderedGroupIds: readonly string[]
|
||||
): TabGroupLayoutNode | null {
|
||||
const present: string[] = []
|
||||
collectLayoutGroupIds(layout, present)
|
||||
const presentSet = new Set(present)
|
||||
let next = layout
|
||||
for (const groupId of orderedGroupIds) {
|
||||
if (presentSet.has(groupId)) {
|
||||
continue
|
||||
}
|
||||
const leaf = { type: 'leaf' as const, groupId }
|
||||
next = next
|
||||
? { type: 'split', direction: 'horizontal', first: next, second: leaf, ratio: 0.5 }
|
||||
: leaf
|
||||
presentSet.add(groupId)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function replaceLeaf(
|
||||
node: TabGroupLayoutNode,
|
||||
groupId: string,
|
||||
replacement: TabGroupLayoutNode
|
||||
): TabGroupLayoutNode {
|
||||
if (node.type === 'leaf') {
|
||||
return node.groupId === groupId ? replacement : node
|
||||
}
|
||||
return {
|
||||
...node,
|
||||
first: replaceLeaf(node.first, groupId, replacement),
|
||||
second: replaceLeaf(node.second, groupId, replacement)
|
||||
}
|
||||
}
|
||||
|
||||
export function hasExactTerminalOrphanGroupLayout(
|
||||
layout: TabGroupLayoutNode,
|
||||
expectedGroupIds: ReadonlySet<string>
|
||||
): boolean {
|
||||
const groupIds: string[] = []
|
||||
collectLayoutGroupIds(layout, groupIds)
|
||||
return (
|
||||
groupIds.length === expectedGroupIds.size &&
|
||||
new Set(groupIds).size === groupIds.length &&
|
||||
groupIds.every((groupId) => expectedGroupIds.has(groupId))
|
||||
)
|
||||
}
|
||||
|
||||
export function mergeTerminalOrphanGroupLayout(args: {
|
||||
existingLayout: TabGroupLayoutNode | null | undefined
|
||||
existingGroupIds: readonly string[]
|
||||
proposedLayout: TabGroupLayoutNode | null | undefined
|
||||
proposedGroupIds: readonly string[]
|
||||
mergedGroupIds: readonly string[]
|
||||
}): TabGroupLayoutNode | undefined {
|
||||
const mergedGroupIdSet = new Set(args.mergedGroupIds)
|
||||
const existingGroupIdSet = new Set(args.existingGroupIds)
|
||||
const proposedGroupIdSet = new Set(args.proposedGroupIds)
|
||||
let existing = appendMissingGroups(
|
||||
pruneLayout(args.existingLayout, existingGroupIdSet),
|
||||
args.existingGroupIds
|
||||
)
|
||||
const proposed = appendMissingGroups(
|
||||
pruneLayout(args.proposedLayout, proposedGroupIdSet),
|
||||
args.proposedGroupIds
|
||||
)
|
||||
|
||||
if (!existing) {
|
||||
return appendMissingGroups(proposed, args.mergedGroupIds) ?? undefined
|
||||
}
|
||||
if (!proposed) {
|
||||
return appendMissingGroups(existing, args.mergedGroupIds) ?? undefined
|
||||
}
|
||||
|
||||
const sharedGroupIds = args.proposedGroupIds.filter((groupId) => existingGroupIdSet.has(groupId))
|
||||
const newGroupIds = new Set(
|
||||
args.proposedGroupIds.filter((groupId) => !existingGroupIdSet.has(groupId))
|
||||
)
|
||||
if (newGroupIds.size > 0 && sharedGroupIds.length === 1) {
|
||||
const anchorGroupId = sharedGroupIds[0]!
|
||||
const proposalAtAnchor = pruneLayout(proposed, new Set([anchorGroupId, ...newGroupIds]))
|
||||
if (proposalAtAnchor) {
|
||||
existing = replaceLeaf(existing, anchorGroupId, proposalAtAnchor)
|
||||
}
|
||||
} else if (newGroupIds.size > 0) {
|
||||
const newSubtree = pruneLayout(proposed, newGroupIds)
|
||||
if (newSubtree) {
|
||||
existing = {
|
||||
type: 'split',
|
||||
direction: proposed.type === 'split' ? proposed.direction : 'horizontal',
|
||||
first: existing,
|
||||
second: newSubtree,
|
||||
ratio: proposed.type === 'split' ? proposed.ratio : 0.5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
appendMissingGroups(pruneLayout(existing, mergedGroupIdSet), args.mergedGroupIds) ?? undefined
|
||||
)
|
||||
}
|
||||
@@ -74,6 +74,7 @@ import {
|
||||
} from './web-runtime-wake-terminal-respawn'
|
||||
import { isRuntimeSubscriptionReplayResponse } from '../../../shared/runtime-subscription-replay'
|
||||
import { queueAcceptedWebSessionTerminalSnapshot } from './web-session-terminal-handle-events'
|
||||
import { recoverWebSessionTerminalOrphansBeforeApply } from './web-session-terminal-orphan-recovery'
|
||||
import {
|
||||
clearWebAgentSessionHandoff,
|
||||
clearWebAgentSessionHandoffsForEnvironment,
|
||||
@@ -2684,7 +2685,7 @@ export function useWebSessionTabsSync(): void {
|
||||
timeoutMs: 15_000,
|
||||
expectedEnvironmentPairingRevision
|
||||
})
|
||||
.then((response: RuntimeRpcResponse<unknown>) => {
|
||||
.then(async (response: RuntimeRpcResponse<unknown>) => {
|
||||
if (
|
||||
disposed ||
|
||||
getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentPairingRevision
|
||||
@@ -2700,8 +2701,23 @@ export function useWebSessionTabsSync(): void {
|
||||
console.warn('[web-session-tabs-sync] initial listAll returned an invalid payload')
|
||||
return
|
||||
}
|
||||
const recovered = await Promise.all(
|
||||
result.snapshots.map((snapshot) =>
|
||||
recoverWebSessionTerminalOrphansBeforeApply(
|
||||
useAppStore.getState(),
|
||||
snapshot,
|
||||
environmentId
|
||||
)
|
||||
)
|
||||
)
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
const applicable = recovered.filter(
|
||||
(snapshot): snapshot is RuntimeMobileSessionTabsResult => snapshot !== null
|
||||
)
|
||||
applyWebSessionTabsStorePatch((state) =>
|
||||
applyFreshWebSessionTabsSnapshots(state, result.snapshots, environmentId)
|
||||
applyFreshWebSessionTabsSnapshots(state, applicable, environmentId)
|
||||
)
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -2745,9 +2761,24 @@ export function useWebSessionTabsSync(): void {
|
||||
acceptReplayedWebSessionTabsSnapshot(environmentId, snapshot.worktree)
|
||||
}
|
||||
}
|
||||
applyWebSessionTabsStorePatch((state) =>
|
||||
applyFreshWebSessionTabsSnapshots(state, event.snapshots, environmentId)
|
||||
)
|
||||
void Promise.all(
|
||||
event.snapshots.map((snapshot) =>
|
||||
recoverWebSessionTerminalOrphansBeforeApply(
|
||||
useAppStore.getState(),
|
||||
snapshot,
|
||||
environmentId
|
||||
)
|
||||
)
|
||||
).then((recovered) => {
|
||||
if (!disposed) {
|
||||
const applicable = recovered.filter(
|
||||
(snapshot): snapshot is RuntimeMobileSessionTabsResult => snapshot !== null
|
||||
)
|
||||
applyWebSessionTabsStorePatch((state) =>
|
||||
applyFreshWebSessionTabsSnapshots(state, applicable, environmentId)
|
||||
)
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
if (event.type !== 'snapshot' && event.type !== 'updated') {
|
||||
@@ -2756,9 +2787,17 @@ export function useWebSessionTabsSync(): void {
|
||||
if (replayed) {
|
||||
acceptReplayedWebSessionTabsSnapshot(environmentId, event.worktree)
|
||||
}
|
||||
applyWebSessionTabsStorePatch((state) =>
|
||||
applyFreshWebSessionTabsSnapshot(state, event, environmentId)
|
||||
)
|
||||
void recoverWebSessionTerminalOrphansBeforeApply(
|
||||
useAppStore.getState(),
|
||||
event,
|
||||
environmentId
|
||||
).then((recovered) => {
|
||||
if (!disposed && recovered) {
|
||||
applyWebSessionTabsStorePatch((state) =>
|
||||
applyFreshWebSessionTabsSnapshot(state, recovered, environmentId)
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
onError: (error) => {
|
||||
console.warn('[web-session-tabs-sync] global subscription error:', error.message)
|
||||
@@ -2822,6 +2861,73 @@ export function useWebSessionTabsSync(): void {
|
||||
let requestedInitialTerminal = false
|
||||
let requestedRespawnAfterWake = false
|
||||
let unsubscribe: (() => void) | null = null
|
||||
const applyActiveSnapshot = async (
|
||||
event: RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' },
|
||||
response: RuntimeRpcResponse<unknown>
|
||||
): Promise<void> => {
|
||||
const recovered = await recoverWebSessionTerminalOrphansBeforeApply(
|
||||
useAppStore.getState(),
|
||||
event,
|
||||
environmentId
|
||||
)
|
||||
if (disposed || !recovered) {
|
||||
return
|
||||
}
|
||||
if (isRuntimeSubscriptionReplayResponse(response)) {
|
||||
acceptReplayedWebSessionTabsSnapshot(environmentId, recovered.worktree)
|
||||
}
|
||||
const recoveredEvent: SessionTabsStreamEvent = { ...recovered, type: event.type }
|
||||
const fresh = shouldApplyWebSessionTabsSnapshot(recovered, environmentId)
|
||||
const syncState = useAppStore.getState()
|
||||
const localWorktreeTabs = syncState.tabsByWorktree[activeWorktreeId] ?? []
|
||||
const localTerminalCount = localWorktreeTabs.length
|
||||
const hasLiveLocalPty = localWorktreeTabs.some(
|
||||
(tab) => (syncState.ptyIdsByTabId[tab.id] ?? []).length > 0
|
||||
)
|
||||
const shouldBootstrapInitialTerminal = shouldBootstrapInitialWebRuntimeTerminal({
|
||||
event: recoveredEvent,
|
||||
activeWorktreeId,
|
||||
requestedInitialTerminal,
|
||||
snapshotIsFresh: fresh,
|
||||
localTerminalCount
|
||||
})
|
||||
const shouldRespawnAfterWake = shouldRespawnWebRuntimeTerminalAfterWake({
|
||||
event: recoveredEvent,
|
||||
activeWorktreeId,
|
||||
requestedRespawnAfterWake,
|
||||
snapshotIsFresh: fresh,
|
||||
localTerminalCount,
|
||||
hasLiveLocalPty,
|
||||
skipWakeRespawn: shouldSkipWebRuntimeWakeTerminalRespawn(activeWorktreeId)
|
||||
})
|
||||
if (fresh) {
|
||||
applyWebSessionTabsStorePatch((state) =>
|
||||
applyWebSessionTabsSnapshot(state, recovered, environmentId)
|
||||
)
|
||||
}
|
||||
if (!disposed && shouldBootstrapInitialTerminal) {
|
||||
requestedInitialTerminal = true
|
||||
void createWebRuntimeSessionTerminal({
|
||||
worktreeId: activeWorktreeId,
|
||||
environmentId,
|
||||
activate: true
|
||||
})
|
||||
} else if (
|
||||
!disposed &&
|
||||
shouldRespawnAfterWake &&
|
||||
beginWebRuntimeWakeTerminalRespawn(activeWorktreeId)
|
||||
) {
|
||||
requestedRespawnAfterWake = true
|
||||
void createWebRuntimeSessionTerminal({
|
||||
worktreeId: activeWorktreeId,
|
||||
environmentId,
|
||||
activate: true,
|
||||
selectWorktree: false
|
||||
}).finally(() => {
|
||||
endWebRuntimeWakeTerminalRespawn(activeWorktreeId)
|
||||
})
|
||||
}
|
||||
}
|
||||
void window.api.runtimeEnvironments
|
||||
.subscribe(
|
||||
{
|
||||
@@ -2847,60 +2953,7 @@ export function useWebSessionTabsSync(): void {
|
||||
if (event.type !== 'snapshot' && event.type !== 'updated') {
|
||||
return
|
||||
}
|
||||
if (isRuntimeSubscriptionReplayResponse(response)) {
|
||||
acceptReplayedWebSessionTabsSnapshot(environmentId, event.worktree)
|
||||
}
|
||||
const fresh = shouldApplyWebSessionTabsSnapshot(event, environmentId)
|
||||
const syncState = useAppStore.getState()
|
||||
const localWorktreeTabs = syncState.tabsByWorktree[activeWorktreeId] ?? []
|
||||
const localTerminalCount = localWorktreeTabs.length
|
||||
const hasLiveLocalPty = localWorktreeTabs.some(
|
||||
(tab) => (syncState.ptyIdsByTabId[tab.id] ?? []).length > 0
|
||||
)
|
||||
const shouldBootstrapInitialTerminal = shouldBootstrapInitialWebRuntimeTerminal({
|
||||
event,
|
||||
activeWorktreeId,
|
||||
requestedInitialTerminal,
|
||||
snapshotIsFresh: fresh,
|
||||
localTerminalCount
|
||||
})
|
||||
const shouldRespawnAfterWake = shouldRespawnWebRuntimeTerminalAfterWake({
|
||||
event,
|
||||
activeWorktreeId,
|
||||
requestedRespawnAfterWake,
|
||||
snapshotIsFresh: fresh,
|
||||
localTerminalCount,
|
||||
hasLiveLocalPty,
|
||||
skipWakeRespawn: shouldSkipWebRuntimeWakeTerminalRespawn(activeWorktreeId)
|
||||
})
|
||||
if (fresh) {
|
||||
applyWebSessionTabsStorePatch((state) =>
|
||||
applyWebSessionTabsSnapshot(state, event, environmentId)
|
||||
)
|
||||
}
|
||||
if (!disposed && shouldBootstrapInitialTerminal) {
|
||||
requestedInitialTerminal = true
|
||||
void createWebRuntimeSessionTerminal({
|
||||
worktreeId: activeWorktreeId,
|
||||
environmentId,
|
||||
activate: true
|
||||
})
|
||||
} else if (
|
||||
!disposed &&
|
||||
shouldRespawnAfterWake &&
|
||||
beginWebRuntimeWakeTerminalRespawn(activeWorktreeId)
|
||||
) {
|
||||
requestedRespawnAfterWake = true
|
||||
// Why: recreate the terminal without changing selected worktree to avoid re-triggering activation churn.
|
||||
void createWebRuntimeSessionTerminal({
|
||||
worktreeId: activeWorktreeId,
|
||||
environmentId,
|
||||
activate: true,
|
||||
selectWorktree: false
|
||||
}).finally(() => {
|
||||
endWebRuntimeWakeTerminalRespawn(activeWorktreeId)
|
||||
})
|
||||
}
|
||||
void applyActiveSnapshot(event, response)
|
||||
},
|
||||
onError: (error) => {
|
||||
console.warn('[web-session-tabs-sync] subscription error:', error.message)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { toRemoteRuntimePtyId } from './runtime-terminal-stream'
|
||||
import {
|
||||
clearWebSessionTerminalOrphanRecoveryForTests,
|
||||
recoverWebSessionTerminalOrphansBeforeApply
|
||||
} from './web-session-terminal-orphan-recovery'
|
||||
|
||||
const worktree = 'repo::/worktree'
|
||||
|
||||
function legacyRecoveryState() {
|
||||
return {
|
||||
tabsByWorktree: {
|
||||
[worktree]: [{ id: 'web-terminal-host-tab', worktreeId: worktree } as never]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'web-terminal-host-tab': {
|
||||
root: { type: 'leaf' as const, leafId: 'leaf-1' },
|
||||
activeLeafId: 'leaf-1',
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: {
|
||||
'leaf-1': toRemoteRuntimePtyId('term_live', 'windows-2')
|
||||
}
|
||||
}
|
||||
},
|
||||
activeTabIdByWorktree: {},
|
||||
activeGroupIdByWorktree: {}
|
||||
}
|
||||
}
|
||||
|
||||
const missingSnapshot = {
|
||||
worktree,
|
||||
publicationEpoch: 'mixed-version',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: null,
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabs: []
|
||||
}
|
||||
|
||||
describe('mixed-version web terminal orphan recovery', () => {
|
||||
beforeEach(() => clearWebSessionTerminalOrphanRecoveryForTests())
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'incarnation evidence is unavailable',
|
||||
result: {
|
||||
terminals: [{ handle: 'term_live', ptyId: 'pty-live', worktreeId: worktree }],
|
||||
totalCount: 1,
|
||||
truncated: false
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'a legacy unfiltered listing truncates before the candidate',
|
||||
result: {
|
||||
terminals: [{ handle: 'term_other', ptyId: 'pty-other', worktreeId: worktree }],
|
||||
totalCount: 101,
|
||||
truncated: true
|
||||
}
|
||||
}
|
||||
])('keeps the live candidate visible when $name', async ({ result }) => {
|
||||
const call = vi.fn(async () => ({ ok: true as const, result }))
|
||||
|
||||
await expect(
|
||||
recoverWebSessionTerminalOrphansBeforeApply(
|
||||
legacyRecoveryState(),
|
||||
missingSnapshot,
|
||||
'windows-2',
|
||||
call as never
|
||||
)
|
||||
).resolves.toBeNull()
|
||||
expect(call).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,449 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { toRemoteRuntimePtyId } from './runtime-terminal-stream'
|
||||
import {
|
||||
clearWebSessionTerminalOrphanRecoveryForTests,
|
||||
recoverWebSessionTerminalOrphansBeforeApply
|
||||
} from './web-session-terminal-orphan-recovery'
|
||||
|
||||
describe('web session terminal orphan recovery', () => {
|
||||
beforeEach(() => clearWebSessionTerminalOrphanRecoveryForTests())
|
||||
|
||||
it('keeps a missing mirror pending until exact live orphan adoption returns', async () => {
|
||||
let resolveAdoption: ((value: never) => void) | null = null
|
||||
const adoptedSnapshot = {
|
||||
worktree: 'repo::C:\\worktree',
|
||||
publicationEpoch: 'adopted',
|
||||
snapshotVersion: 2,
|
||||
activeGroupId: 'group-1',
|
||||
activeTabId: 'host-tab::leaf-1',
|
||||
activeTabType: 'terminal' as const,
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal' as const,
|
||||
id: 'host-tab::leaf-1',
|
||||
parentTabId: 'host-tab',
|
||||
leafId: 'leaf-1',
|
||||
title: 'Claude',
|
||||
isActive: true,
|
||||
status: 'ready' as const,
|
||||
terminal: 'term_live'
|
||||
}
|
||||
]
|
||||
}
|
||||
const call = vi.fn(async ({ method }) => {
|
||||
if (method === 'terminal.list') {
|
||||
return {
|
||||
ok: true as const,
|
||||
result: {
|
||||
terminals: [
|
||||
{
|
||||
handle: 'term_live',
|
||||
ptyId: 'native-pty',
|
||||
incarnationId: 'inc-1',
|
||||
orphaned: true,
|
||||
worktreeId: adoptedSnapshot.worktree
|
||||
}
|
||||
],
|
||||
topologyRevisions: { [adoptedSnapshot.worktree]: 0 },
|
||||
totalCount: 1,
|
||||
truncated: false
|
||||
}
|
||||
}
|
||||
}
|
||||
return await new Promise((resolve) => {
|
||||
resolveAdoption = resolve as (value: never) => void
|
||||
})
|
||||
})
|
||||
const state = {
|
||||
tabsByWorktree: {
|
||||
[adoptedSnapshot.worktree]: [
|
||||
{ id: 'web-terminal-host-tab', worktreeId: adoptedSnapshot.worktree } as never
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'web-terminal-host-tab': {
|
||||
root: { type: 'leaf' as const, leafId: 'leaf-1' },
|
||||
activeLeafId: 'leaf-1',
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { 'leaf-1': toRemoteRuntimePtyId('term_live', 'windows-2') }
|
||||
}
|
||||
},
|
||||
activeTabIdByWorktree: { [adoptedSnapshot.worktree]: 'web-terminal-host-tab' },
|
||||
activeGroupIdByWorktree: { [adoptedSnapshot.worktree]: 'group-1' },
|
||||
groupsByWorktree: {
|
||||
[adoptedSnapshot.worktree]: [
|
||||
{
|
||||
id: 'group-1',
|
||||
worktreeId: adoptedSnapshot.worktree,
|
||||
activeTabId: 'web-terminal-host-tab',
|
||||
tabOrder: ['web-terminal-host-tab']
|
||||
}
|
||||
]
|
||||
},
|
||||
layoutByWorktree: {
|
||||
[adoptedSnapshot.worktree]: { type: 'leaf' as const, groupId: 'group-1' }
|
||||
}
|
||||
}
|
||||
const missingSnapshot = { ...adoptedSnapshot, publicationEpoch: 'missing', tabs: [] }
|
||||
let settled = false
|
||||
const recovery = recoverWebSessionTerminalOrphansBeforeApply(
|
||||
state,
|
||||
missingSnapshot,
|
||||
'windows-2',
|
||||
call as never
|
||||
).then((result) => {
|
||||
settled = true
|
||||
return result
|
||||
})
|
||||
await vi.waitFor(() => expect(resolveAdoption).not.toBeNull())
|
||||
expect(settled).toBe(false)
|
||||
expect(call).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
method: 'terminal.list',
|
||||
params: expect.objectContaining({ handles: ['term_live'] })
|
||||
})
|
||||
)
|
||||
resolveAdoption!({
|
||||
ok: true,
|
||||
result: { adopted: true, topologyRevision: 1, snapshot: adoptedSnapshot }
|
||||
} as never)
|
||||
|
||||
await expect(recovery).resolves.toEqual(adoptedSnapshot)
|
||||
expect(call).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'terminal.adoptOrphans',
|
||||
params: expect.objectContaining({
|
||||
expectedTopologyRevision: 0,
|
||||
activeTabId: 'host-tab',
|
||||
claims: [
|
||||
expect.objectContaining({
|
||||
terminal: 'term_live',
|
||||
ptyId: 'native-pty',
|
||||
incarnationId: 'inc-1',
|
||||
tabId: 'host-tab',
|
||||
leafId: 'leaf-1'
|
||||
})
|
||||
],
|
||||
topology: {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'host-tab',
|
||||
root: { type: 'leaf', leafId: 'leaf-1' },
|
||||
activeLeafId: 'leaf-1',
|
||||
expandedLeafId: null
|
||||
}
|
||||
],
|
||||
groups: [{ id: 'group-1', activeTabId: 'host-tab', tabOrder: ['host-tab'] }],
|
||||
groupLayout: { type: 'leaf', groupId: 'group-1' }
|
||||
}
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not apply absence when an exact recoverable orphan cannot be adopted yet', async () => {
|
||||
const worktree = 'repo::/worktree'
|
||||
const call = vi.fn(async ({ method }) =>
|
||||
method === 'terminal.list'
|
||||
? {
|
||||
ok: true as const,
|
||||
result: {
|
||||
terminals: [
|
||||
{
|
||||
handle: 'term_live',
|
||||
ptyId: 'pty-live',
|
||||
incarnationId: 'inc-live',
|
||||
orphaned: true
|
||||
}
|
||||
],
|
||||
topologyRevisions: { [worktree]: 4 },
|
||||
totalCount: 1,
|
||||
truncated: false
|
||||
}
|
||||
}
|
||||
: { ok: false as const, error: { code: 'conflict', message: 'retry' } }
|
||||
)
|
||||
const state = {
|
||||
tabsByWorktree: {
|
||||
[worktree]: [{ id: 'web-terminal-host-tab', worktreeId: worktree } as never]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'web-terminal-host-tab': {
|
||||
root: { type: 'leaf' as const, leafId: 'leaf-1' },
|
||||
activeLeafId: 'leaf-1',
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: {
|
||||
'leaf-1': toRemoteRuntimePtyId('term_live', 'windows-2')
|
||||
}
|
||||
}
|
||||
},
|
||||
activeTabIdByWorktree: {},
|
||||
activeGroupIdByWorktree: {}
|
||||
}
|
||||
const missing = {
|
||||
worktree,
|
||||
publicationEpoch: 'missing',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: null,
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabs: []
|
||||
}
|
||||
|
||||
await expect(
|
||||
recoverWebSessionTerminalOrphansBeforeApply(state, missing, 'windows-2', call as never)
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('proposes pruned pane and group topology using host tab identities', async () => {
|
||||
const worktree = 'repo::/worktree'
|
||||
const adoptedSnapshot = {
|
||||
worktree,
|
||||
publicationEpoch: 'adopted',
|
||||
snapshotVersion: 2,
|
||||
activeGroupId: 'group-right',
|
||||
activeTabId: 'shell-tab',
|
||||
activeTabType: 'terminal' as const,
|
||||
tabs: []
|
||||
}
|
||||
const call = vi.fn(async ({ method }) =>
|
||||
method === 'terminal.list'
|
||||
? {
|
||||
ok: true as const,
|
||||
result: {
|
||||
terminals: [
|
||||
{
|
||||
handle: 'term_agent',
|
||||
ptyId: 'pty-agent',
|
||||
incarnationId: 'inc-agent',
|
||||
orphaned: true
|
||||
},
|
||||
{
|
||||
handle: 'term_setup',
|
||||
ptyId: 'pty-setup',
|
||||
incarnationId: 'inc-setup',
|
||||
orphaned: true
|
||||
},
|
||||
{
|
||||
handle: 'term_shell',
|
||||
ptyId: 'pty-shell',
|
||||
incarnationId: 'inc-shell',
|
||||
orphaned: true
|
||||
}
|
||||
],
|
||||
topologyRevisions: { [worktree]: 8 },
|
||||
totalCount: 3,
|
||||
truncated: false
|
||||
}
|
||||
}
|
||||
: {
|
||||
ok: true as const,
|
||||
result: { adopted: true, topologyRevision: 9, snapshot: adoptedSnapshot }
|
||||
}
|
||||
)
|
||||
const state = {
|
||||
tabsByWorktree: {
|
||||
[worktree]: [
|
||||
{ id: 'web-terminal-agent-tab', worktreeId: worktree } as never,
|
||||
{ id: 'web-terminal-shell-tab', worktreeId: worktree } as never
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'web-terminal-agent-tab': {
|
||||
root: {
|
||||
type: 'split' as const,
|
||||
direction: 'horizontal' as const,
|
||||
ratio: 0.7,
|
||||
first: { type: 'leaf' as const, leafId: 'leaf-agent' },
|
||||
second: { type: 'leaf' as const, leafId: 'leaf-setup' }
|
||||
},
|
||||
activeLeafId: 'leaf-setup',
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: {
|
||||
'leaf-agent': toRemoteRuntimePtyId('term_agent', 'windows-2'),
|
||||
'leaf-setup': toRemoteRuntimePtyId('term_setup', 'windows-2')
|
||||
}
|
||||
},
|
||||
'web-terminal-shell-tab': {
|
||||
root: { type: 'leaf' as const, leafId: 'leaf-shell' },
|
||||
activeLeafId: 'leaf-shell',
|
||||
expandedLeafId: 'leaf-shell',
|
||||
ptyIdsByLeafId: {
|
||||
'leaf-shell': toRemoteRuntimePtyId('term_shell', 'windows-2')
|
||||
}
|
||||
}
|
||||
},
|
||||
activeTabIdByWorktree: { [worktree]: 'web-terminal-shell-tab' },
|
||||
activeGroupIdByWorktree: { [worktree]: 'group-right' },
|
||||
groupsByWorktree: {
|
||||
[worktree]: [
|
||||
{
|
||||
id: 'group-left',
|
||||
worktreeId: worktree,
|
||||
activeTabId: 'web-terminal-agent-tab',
|
||||
tabOrder: ['web-terminal-agent-tab']
|
||||
},
|
||||
{
|
||||
id: 'group-right',
|
||||
worktreeId: worktree,
|
||||
activeTabId: 'web-terminal-shell-tab',
|
||||
tabOrder: ['web-terminal-shell-tab']
|
||||
}
|
||||
]
|
||||
},
|
||||
layoutByWorktree: {
|
||||
[worktree]: {
|
||||
type: 'split' as const,
|
||||
direction: 'vertical' as const,
|
||||
ratio: 0.6,
|
||||
first: { type: 'leaf' as const, groupId: 'group-left' },
|
||||
second: { type: 'leaf' as const, groupId: 'group-right' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await expect(
|
||||
recoverWebSessionTerminalOrphansBeforeApply(
|
||||
state,
|
||||
{ ...adoptedSnapshot, publicationEpoch: 'missing' },
|
||||
'windows-2',
|
||||
call as never
|
||||
)
|
||||
).resolves.toEqual(adoptedSnapshot)
|
||||
expect(call).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'terminal.adoptOrphans',
|
||||
params: expect.objectContaining({
|
||||
expectedTopologyRevision: 8,
|
||||
activeTabId: 'shell-tab',
|
||||
activeGroupId: 'group-right',
|
||||
topology: {
|
||||
tabs: [
|
||||
expect.objectContaining({
|
||||
tabId: 'agent-tab',
|
||||
root: expect.objectContaining({
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
ratio: 0.7
|
||||
}),
|
||||
activeLeafId: 'leaf-setup'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
tabId: 'shell-tab',
|
||||
expandedLeafId: 'leaf-shell'
|
||||
})
|
||||
],
|
||||
groups: [
|
||||
expect.objectContaining({
|
||||
id: 'group-left',
|
||||
activeTabId: 'agent-tab',
|
||||
tabOrder: ['agent-tab']
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'group-right',
|
||||
activeTabId: 'shell-tab',
|
||||
tabOrder: ['shell-tab']
|
||||
})
|
||||
],
|
||||
groupLayout: expect.objectContaining({
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
ratio: 0.6
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('serializes a newer convergence snapshot after an in-flight adoption conflict', async () => {
|
||||
const worktree = 'repo::/worktree'
|
||||
let rejectFirstAdoption: (() => void) | null = null
|
||||
const call = vi.fn(async ({ method }) => {
|
||||
if (method === 'terminal.list') {
|
||||
return {
|
||||
ok: true as const,
|
||||
result: {
|
||||
terminals: [
|
||||
{
|
||||
handle: 'term_live',
|
||||
ptyId: 'pty-live',
|
||||
incarnationId: 'inc-live',
|
||||
orphaned: true
|
||||
}
|
||||
],
|
||||
topologyRevisions: { [worktree]: 1 },
|
||||
totalCount: 1,
|
||||
truncated: false
|
||||
}
|
||||
}
|
||||
}
|
||||
return await new Promise((resolve) => {
|
||||
rejectFirstAdoption = () =>
|
||||
resolve({ ok: false as const, error: { code: 'conflict', message: 'closed' } })
|
||||
})
|
||||
})
|
||||
const state = {
|
||||
tabsByWorktree: {
|
||||
[worktree]: [{ id: 'web-terminal-host-tab', worktreeId: worktree } as never]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'web-terminal-host-tab': {
|
||||
root: { type: 'leaf' as const, leafId: 'leaf-1' },
|
||||
activeLeafId: 'leaf-1',
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: {
|
||||
'leaf-1': toRemoteRuntimePtyId('term_live', 'windows-2')
|
||||
}
|
||||
}
|
||||
},
|
||||
activeTabIdByWorktree: {},
|
||||
activeGroupIdByWorktree: {}
|
||||
}
|
||||
const missing = {
|
||||
worktree,
|
||||
publicationEpoch: 'missing',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: null,
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabs: []
|
||||
}
|
||||
const converged = {
|
||||
...missing,
|
||||
publicationEpoch: 'closed',
|
||||
snapshotVersion: 2,
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal' as const,
|
||||
id: 'host-tab::leaf-1',
|
||||
parentTabId: 'host-tab',
|
||||
leafId: 'leaf-1',
|
||||
title: 'closed',
|
||||
isActive: false,
|
||||
status: 'pending-handle' as const,
|
||||
terminal: null
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const first = recoverWebSessionTerminalOrphansBeforeApply(
|
||||
state,
|
||||
missing,
|
||||
'windows-2',
|
||||
call as never
|
||||
)
|
||||
await vi.waitFor(() => expect(rejectFirstAdoption).not.toBeNull())
|
||||
const second = recoverWebSessionTerminalOrphansBeforeApply(
|
||||
state,
|
||||
converged,
|
||||
'windows-2',
|
||||
call as never
|
||||
)
|
||||
rejectFirstAdoption!()
|
||||
|
||||
await expect(first).resolves.toBeNull()
|
||||
await expect(second).resolves.toEqual(converged)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,196 @@
|
||||
import type {
|
||||
RuntimeMobileSessionTabsResult,
|
||||
RuntimeTerminalListResult,
|
||||
RuntimeTerminalOrphanAdoptionResult
|
||||
} from '../../../shared/runtime-types'
|
||||
import type { TerminalTab } from '../../../shared/types'
|
||||
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
|
||||
import { parseRemoteRuntimePtyId } from './runtime-terminal-stream'
|
||||
import { toRuntimeWorktreeSelector } from './runtime-worktree-selector'
|
||||
import { isWebTerminalSurfaceTabId, toHostSessionTabId } from './web-terminal-surface-id'
|
||||
import {
|
||||
buildWebTerminalOrphanTopologyProposal,
|
||||
type WebTerminalOrphanTopologyState
|
||||
} from './web-session-terminal-orphan-topology'
|
||||
|
||||
type TerminalOrphanRecoveryState = WebTerminalOrphanTopologyState & {
|
||||
tabsByWorktree: Record<string, TerminalTab[]>
|
||||
}
|
||||
|
||||
type RuntimeCall = (args: {
|
||||
selector: string
|
||||
method: string
|
||||
params: unknown
|
||||
timeoutMs: number
|
||||
}) => Promise<RuntimeRpcResponse<unknown>>
|
||||
|
||||
const inFlightRecoveryByWorktree = new Map<string, Promise<RuntimeMobileSessionTabsResult | null>>()
|
||||
|
||||
function recoveryKey(environmentId: string, worktreeId: string): string {
|
||||
return `${environmentId}\0${worktreeId}`
|
||||
}
|
||||
|
||||
function isTerminalListResult(value: unknown): value is RuntimeTerminalListResult {
|
||||
return (
|
||||
Boolean(value) &&
|
||||
typeof value === 'object' &&
|
||||
Array.isArray((value as { terminals?: unknown }).terminals)
|
||||
)
|
||||
}
|
||||
|
||||
function isAdoptionResult(value: unknown): value is RuntimeTerminalOrphanAdoptionResult {
|
||||
return (
|
||||
Boolean(value) &&
|
||||
typeof value === 'object' &&
|
||||
Boolean((value as { snapshot?: unknown }).snapshot) &&
|
||||
Array.isArray((value as { snapshot?: { tabs?: unknown } }).snapshot?.tabs)
|
||||
)
|
||||
}
|
||||
|
||||
async function recoverTerminalOrphans(
|
||||
state: TerminalOrphanRecoveryState,
|
||||
snapshot: RuntimeMobileSessionTabsResult,
|
||||
environmentId: string,
|
||||
call: RuntimeCall
|
||||
): Promise<RuntimeMobileSessionTabsResult | null> {
|
||||
const hostTabIds = new Set(
|
||||
snapshot.tabs.filter((tab) => tab.type === 'terminal').map((tab) => tab.parentTabId)
|
||||
)
|
||||
const candidates = (state.tabsByWorktree[snapshot.worktree] ?? []).filter(
|
||||
(tab) => isWebTerminalSurfaceTabId(tab.id) && !hostTabIds.has(toHostSessionTabId(tab.id))
|
||||
)
|
||||
if (candidates.length === 0) {
|
||||
return snapshot
|
||||
}
|
||||
const candidateSurfaces = candidates.flatMap((tab) => {
|
||||
const layout = state.terminalLayoutsByTabId[tab.id]
|
||||
return Object.entries(layout?.ptyIdsByLeafId ?? {}).flatMap(([leafId, remotePtyId]) => {
|
||||
const remote = parseRemoteRuntimePtyId(remotePtyId)
|
||||
return remote?.environmentId === environmentId
|
||||
? [{ tabId: toHostSessionTabId(tab.id), leafId, handle: remote.handle }]
|
||||
: []
|
||||
})
|
||||
})
|
||||
const candidateHandles = new Set(candidateSurfaces.map((surface) => surface.handle))
|
||||
if (candidateHandles.size === 0) {
|
||||
return snapshot
|
||||
}
|
||||
if (candidateHandles.size > 64) {
|
||||
return null
|
||||
}
|
||||
const listedResponse = await call({
|
||||
selector: environmentId,
|
||||
method: 'terminal.list',
|
||||
params: {
|
||||
worktree: toRuntimeWorktreeSelector(snapshot.worktree),
|
||||
handles: [...candidateHandles],
|
||||
requireFreshPtyLiveness: true
|
||||
},
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
if (listedResponse.ok === false || !isTerminalListResult(listedResponse.result)) {
|
||||
return null
|
||||
}
|
||||
const listed = listedResponse.result
|
||||
const orphanByHandle = new Map(
|
||||
listed.terminals
|
||||
.filter(
|
||||
(terminal) =>
|
||||
terminal.orphaned === true &&
|
||||
typeof terminal.ptyId === 'string' &&
|
||||
typeof terminal.incarnationId === 'string'
|
||||
)
|
||||
.map((terminal) => [terminal.handle, terminal])
|
||||
)
|
||||
const claims = candidateSurfaces.flatMap(({ tabId, leafId, handle }) => {
|
||||
const orphan = orphanByHandle.get(handle)
|
||||
if (!orphan?.ptyId || !orphan.incarnationId) {
|
||||
return []
|
||||
}
|
||||
return [
|
||||
{
|
||||
terminal: orphan.handle,
|
||||
ptyId: orphan.ptyId,
|
||||
incarnationId: orphan.incarnationId,
|
||||
tabId,
|
||||
leafId
|
||||
}
|
||||
]
|
||||
})
|
||||
const claimedHandles = new Set(claims.map((claim) => claim.terminal))
|
||||
const listedCandidateHandles = new Set(
|
||||
listed.terminals
|
||||
.filter((terminal) => candidateHandles.has(terminal.handle))
|
||||
.map((terminal) => terminal.handle)
|
||||
)
|
||||
if (
|
||||
listed.truncated &&
|
||||
[...candidateHandles].some((handle) => !listedCandidateHandles.has(handle))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const hasUnresolvedLiveCandidate = listed.terminals.some(
|
||||
(terminal) => candidateHandles.has(terminal.handle) && !claimedHandles.has(terminal.handle)
|
||||
)
|
||||
if (hasUnresolvedLiveCandidate) {
|
||||
return null
|
||||
}
|
||||
if (claims.length === 0) {
|
||||
return snapshot
|
||||
}
|
||||
const localActiveTabId = state.activeTabIdByWorktree[snapshot.worktree]
|
||||
const activeTabId =
|
||||
localActiveTabId && isWebTerminalSurfaceTabId(localActiveTabId)
|
||||
? toHostSessionTabId(localActiveTabId)
|
||||
: undefined
|
||||
const activeGroupId = state.activeGroupIdByWorktree[snapshot.worktree] ?? undefined
|
||||
const topology = buildWebTerminalOrphanTopologyProposal(
|
||||
state,
|
||||
snapshot.worktree,
|
||||
candidates,
|
||||
claims
|
||||
)
|
||||
const response = await call({
|
||||
selector: environmentId,
|
||||
method: 'terminal.adoptOrphans',
|
||||
params: {
|
||||
worktree: toRuntimeWorktreeSelector(snapshot.worktree),
|
||||
expectedTopologyRevision: listed.topologyRevisions?.[snapshot.worktree] ?? 0,
|
||||
claims,
|
||||
...(activeTabId ? { activeTabId } : {}),
|
||||
...(activeGroupId ? { activeGroupId } : {}),
|
||||
...(topology ? { topology } : {})
|
||||
},
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
return response.ok !== false &&
|
||||
isAdoptionResult(response.result) &&
|
||||
response.result.snapshot.worktree === snapshot.worktree
|
||||
? response.result.snapshot
|
||||
: null
|
||||
}
|
||||
|
||||
export function recoverWebSessionTerminalOrphansBeforeApply(
|
||||
state: TerminalOrphanRecoveryState,
|
||||
snapshot: RuntimeMobileSessionTabsResult,
|
||||
environmentId: string,
|
||||
call: RuntimeCall = (args) => window.api.runtimeEnvironments.call(args)
|
||||
): Promise<RuntimeMobileSessionTabsResult | null> {
|
||||
const key = recoveryKey(environmentId, snapshot.worktree)
|
||||
const existing = inFlightRecoveryByWorktree.get(key)
|
||||
const recovery = (existing ?? Promise.resolve(null))
|
||||
.catch(() => null)
|
||||
.then(() => recoverTerminalOrphans(state, snapshot, environmentId, call))
|
||||
.catch(() => null)
|
||||
.finally(() => {
|
||||
if (inFlightRecoveryByWorktree.get(key) === recovery) {
|
||||
inFlightRecoveryByWorktree.delete(key)
|
||||
}
|
||||
})
|
||||
inFlightRecoveryByWorktree.set(key, recovery)
|
||||
return recovery
|
||||
}
|
||||
|
||||
export function clearWebSessionTerminalOrphanRecoveryForTests(): void {
|
||||
inFlightRecoveryByWorktree.clear()
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { RuntimeTerminalOrphanTopology } from '../../../shared/runtime-types'
|
||||
import type {
|
||||
TabGroup,
|
||||
TabGroupLayoutNode,
|
||||
TerminalLayoutSnapshot,
|
||||
TerminalPaneLayoutNode,
|
||||
TerminalTab
|
||||
} from '../../../shared/types'
|
||||
import { toHostSessionTabId } from './web-terminal-surface-id'
|
||||
|
||||
export type WebTerminalOrphanTopologyState = {
|
||||
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot>
|
||||
activeTabIdByWorktree: Record<string, string | null | undefined>
|
||||
activeGroupIdByWorktree: Record<string, string | null | undefined>
|
||||
groupsByWorktree?: Record<string, TabGroup[] | undefined>
|
||||
layoutByWorktree?: Record<string, TabGroupLayoutNode | undefined>
|
||||
}
|
||||
|
||||
function prunePaneLayout(
|
||||
node: TerminalPaneLayoutNode | null,
|
||||
retainedLeafIds: ReadonlySet<string>
|
||||
): TerminalPaneLayoutNode | null {
|
||||
if (!node) {
|
||||
return null
|
||||
}
|
||||
if (node.type === 'leaf') {
|
||||
return retainedLeafIds.has(node.leafId) ? node : null
|
||||
}
|
||||
const first = prunePaneLayout(node.first, retainedLeafIds)
|
||||
const second = prunePaneLayout(node.second, retainedLeafIds)
|
||||
if (!first) {
|
||||
return second
|
||||
}
|
||||
if (!second) {
|
||||
return first
|
||||
}
|
||||
return { ...node, first, second }
|
||||
}
|
||||
|
||||
function pruneGroupLayout(
|
||||
node: TabGroupLayoutNode | undefined,
|
||||
retainedGroupIds: ReadonlySet<string>
|
||||
): TabGroupLayoutNode | undefined {
|
||||
if (!node) {
|
||||
return undefined
|
||||
}
|
||||
if (node.type === 'leaf') {
|
||||
return retainedGroupIds.has(node.groupId) ? node : undefined
|
||||
}
|
||||
const first = pruneGroupLayout(node.first, retainedGroupIds)
|
||||
const second = pruneGroupLayout(node.second, retainedGroupIds)
|
||||
if (!first) {
|
||||
return second
|
||||
}
|
||||
if (!second) {
|
||||
return first
|
||||
}
|
||||
return { ...node, first, second }
|
||||
}
|
||||
|
||||
export function buildWebTerminalOrphanTopologyProposal(
|
||||
state: WebTerminalOrphanTopologyState,
|
||||
worktreeId: string,
|
||||
candidates: readonly TerminalTab[],
|
||||
claims: readonly { tabId: string; leafId: string }[]
|
||||
): RuntimeTerminalOrphanTopology | undefined {
|
||||
const leafIdsByTabId = new Map<string, Set<string>>()
|
||||
for (const claim of claims) {
|
||||
const leafIds = leafIdsByTabId.get(claim.tabId) ?? new Set<string>()
|
||||
leafIds.add(claim.leafId)
|
||||
leafIdsByTabId.set(claim.tabId, leafIds)
|
||||
}
|
||||
const hostTabIdByLocalId = new Map(
|
||||
candidates.map((tab) => [tab.id, toHostSessionTabId(tab.id)] as const)
|
||||
)
|
||||
const tabs = candidates.flatMap((tab) => {
|
||||
const tabId = hostTabIdByLocalId.get(tab.id)!
|
||||
const retainedLeafIds = leafIdsByTabId.get(tabId)
|
||||
const layout = state.terminalLayoutsByTabId[tab.id]
|
||||
const root = retainedLeafIds ? prunePaneLayout(layout?.root ?? null, retainedLeafIds) : null
|
||||
if (!layout || !root || !retainedLeafIds || retainedLeafIds.size === 0) {
|
||||
return []
|
||||
}
|
||||
const fallbackLeafId = [...retainedLeafIds][0]!
|
||||
return [
|
||||
{
|
||||
tabId,
|
||||
root,
|
||||
activeLeafId: retainedLeafIds.has(layout.activeLeafId ?? '')
|
||||
? layout.activeLeafId!
|
||||
: fallbackLeafId,
|
||||
expandedLeafId:
|
||||
layout.expandedLeafId && retainedLeafIds.has(layout.expandedLeafId)
|
||||
? layout.expandedLeafId
|
||||
: null
|
||||
}
|
||||
]
|
||||
})
|
||||
if (tabs.length !== leafIdsByTabId.size) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const adoptedTabIds = new Set(tabs.map((tab) => tab.tabId))
|
||||
const groups = (state.groupsByWorktree?.[worktreeId] ?? []).flatMap((group) => {
|
||||
const tabOrder = group.tabOrder
|
||||
.map((tabId) => hostTabIdByLocalId.get(tabId))
|
||||
.filter((tabId): tabId is string => Boolean(tabId && adoptedTabIds.has(tabId)))
|
||||
if (tabOrder.length === 0) {
|
||||
return []
|
||||
}
|
||||
const requestedActive = group.activeTabId
|
||||
? hostTabIdByLocalId.get(group.activeTabId)
|
||||
: undefined
|
||||
const recentTabIds = group.recentTabIds
|
||||
?.map((tabId) => hostTabIdByLocalId.get(tabId))
|
||||
.filter((tabId): tabId is string => Boolean(tabId && tabOrder.includes(tabId)))
|
||||
return [
|
||||
{
|
||||
id: group.id,
|
||||
activeTabId:
|
||||
requestedActive && tabOrder.includes(requestedActive) ? requestedActive : tabOrder[0]!,
|
||||
tabOrder,
|
||||
...(recentTabIds && recentTabIds.length > 0 ? { recentTabIds } : {})
|
||||
}
|
||||
]
|
||||
})
|
||||
const completeGroups =
|
||||
groups.length > 0
|
||||
? groups
|
||||
: [
|
||||
{
|
||||
id: state.activeGroupIdByWorktree[worktreeId] ?? 'recovered-orphans',
|
||||
activeTabId: tabs[0]!.tabId,
|
||||
tabOrder: tabs.map((tab) => tab.tabId)
|
||||
}
|
||||
]
|
||||
const groupIds = new Set(completeGroups.map((group) => group.id))
|
||||
const groupLayout = pruneGroupLayout(state.layoutByWorktree?.[worktreeId], groupIds)
|
||||
return { tabs, groups: completeGroups, ...(groupLayout ? { groupLayout } : {}) }
|
||||
}
|
||||
@@ -417,6 +417,8 @@ export type RuntimeFileReadChunkResult = {
|
||||
export type RuntimeTerminalSummary = {
|
||||
handle: string
|
||||
ptyId: string | null
|
||||
incarnationId?: string | null
|
||||
orphaned?: boolean
|
||||
worktreeId: string
|
||||
worktreePath: string
|
||||
branch: string
|
||||
@@ -480,10 +482,54 @@ export type RuntimeTerminalVisualLayout = {
|
||||
export type RuntimeTerminalListResult = {
|
||||
terminals: RuntimeTerminalSummary[]
|
||||
visualLayouts?: RuntimeTerminalVisualLayout[]
|
||||
topologyRevisions?: Record<string, number>
|
||||
totalCount: number
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
export type RuntimeTerminalOrphanAdoptionClaim = {
|
||||
terminal: string
|
||||
ptyId: string
|
||||
incarnationId: string
|
||||
tabId: string
|
||||
leafId: string
|
||||
}
|
||||
|
||||
export type RuntimeTerminalOrphanTopologyTab = {
|
||||
tabId: string
|
||||
root: TerminalPaneLayoutNode
|
||||
activeLeafId: string
|
||||
expandedLeafId: string | null
|
||||
}
|
||||
|
||||
export type RuntimeTerminalOrphanTopologyGroup = {
|
||||
id: string
|
||||
activeTabId: string
|
||||
tabOrder: string[]
|
||||
recentTabIds?: string[]
|
||||
}
|
||||
|
||||
export type RuntimeTerminalOrphanTopology = {
|
||||
tabs: RuntimeTerminalOrphanTopologyTab[]
|
||||
groups: RuntimeTerminalOrphanTopologyGroup[]
|
||||
groupLayout?: TabGroupLayoutNode
|
||||
}
|
||||
|
||||
export type RuntimeTerminalOrphanAdoptionRequest = {
|
||||
worktree: string
|
||||
expectedTopologyRevision: number
|
||||
claims: RuntimeTerminalOrphanAdoptionClaim[]
|
||||
activeTabId?: string
|
||||
activeGroupId?: string
|
||||
topology?: RuntimeTerminalOrphanTopology
|
||||
}
|
||||
|
||||
export type RuntimeTerminalOrphanAdoptionResult = {
|
||||
adopted: boolean
|
||||
topologyRevision: number
|
||||
snapshot: RuntimeMobileSessionTabsResult
|
||||
}
|
||||
|
||||
export type RuntimeWorktreeTerminalSleepFailure =
|
||||
| 'terminal_liveness_unavailable'
|
||||
| 'terminal_worktree_sleep_still_live'
|
||||
|
||||
Reference in New Issue
Block a user