mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 08:03:33 +00:00
fix(mobile): reattach restored session terminals
This commit is contained in:
@@ -24,8 +24,7 @@ vi.mock('electron', () => electronMocks)
|
||||
const TAB_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const LEAF_ID = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
function makeSession(worktreeId: string): WorkspaceSessionState {
|
||||
const ptyId = `${worktreeId}@@race-pty`
|
||||
function makeSession(worktreeId: string, ptyId = `${worktreeId}@@race-pty`): WorkspaceSessionState {
|
||||
return {
|
||||
...getDefaultWorkspaceSession(),
|
||||
activeWorktreeId: worktreeId,
|
||||
@@ -59,8 +58,12 @@ function makeSession(worktreeId: string): WorkspaceSessionState {
|
||||
}
|
||||
}
|
||||
|
||||
function createRuntime(folders: FolderWorkspace[], group: ProjectGroup): OrcaRuntimeService {
|
||||
const session = makeSession(`folder:${folders[0]!.id}`)
|
||||
function createRuntime(
|
||||
folders: FolderWorkspace[],
|
||||
group: ProjectGroup,
|
||||
ptyId?: string
|
||||
): OrcaRuntimeService {
|
||||
const session = makeSession(`folder:${folders[0]!.id}`, ptyId)
|
||||
return new OrcaRuntimeService({
|
||||
getRepo: () => undefined,
|
||||
getRepos: () => [],
|
||||
@@ -86,6 +89,104 @@ function createRuntime(folders: FolderWorkspace[], group: ProjectGroup): OrcaRun
|
||||
}
|
||||
|
||||
describe('mobile folder session mutation races', () => {
|
||||
it('reattaches an inventory-restored serve terminal before folder input is writable', async () => {
|
||||
const ptyId = 'serve-restarted-folder-pty'
|
||||
const folder: FolderWorkspace = {
|
||||
id: 'restored-folder',
|
||||
projectGroupId: 'restored-folder-group',
|
||||
name: 'Restored folder',
|
||||
folderPath: '/workspace/restored-folder',
|
||||
connectionId: null,
|
||||
linkedTask: null,
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 1,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
const group: ProjectGroup = {
|
||||
id: folder.projectGroupId,
|
||||
name: folder.projectGroupId,
|
||||
parentPath: folder.folderPath,
|
||||
connectionId: null,
|
||||
parentGroupId: null,
|
||||
createdFrom: 'manual',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
const folderKey = `folder:${folder.id}`
|
||||
const incarnationId = '33333333-3333-4333-8333-333333333333'
|
||||
const runtime = createRuntime([folder], group, ptyId)
|
||||
vi.spyOn(
|
||||
runtime as unknown as {
|
||||
resolveTerminalWorkspaceLaunchScope: () => Promise<{
|
||||
id: string
|
||||
path: string
|
||||
connectionId: null
|
||||
repo: null
|
||||
folderWorkspace: FolderWorkspace
|
||||
}>
|
||||
},
|
||||
'resolveTerminalWorkspaceLaunchScope'
|
||||
).mockResolvedValue({
|
||||
id: folderKey,
|
||||
path: folder.folderPath,
|
||||
connectionId: null,
|
||||
repo: null,
|
||||
folderWorkspace: folder
|
||||
})
|
||||
let attached = false
|
||||
const spawn = vi.fn(async () => {
|
||||
attached = true
|
||||
return { id: ptyId, incarnationId }
|
||||
})
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => attached,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => [
|
||||
{
|
||||
id: ptyId,
|
||||
cwd: folder.folderPath,
|
||||
title: 'Restored folder terminal',
|
||||
worktreeId: folderKey,
|
||||
terminalHandle: 'term_restarted_folder',
|
||||
incarnationId
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const listed = await runtime.listMobileSessionTabs(`id:${folderKey}`)
|
||||
const listedTab = listed.tabs[0]
|
||||
expect(listedTab).toMatchObject({ status: 'ready', terminal: 'term_restarted_folder' })
|
||||
await expect(
|
||||
runtime.sendTerminal(listedTab?.type === 'terminal' ? listedTab.terminal! : 'missing', {
|
||||
text: 'before attach'
|
||||
})
|
||||
).rejects.toThrow('terminal_not_writable')
|
||||
|
||||
const activated = await runtime.activateMobileSessionTab(`id:${folderKey}`, TAB_ID)
|
||||
const activatedTab = activated.tabs[0]
|
||||
await expect(
|
||||
runtime.sendTerminal(activatedTab?.type === 'terminal' ? activatedTab.terminal! : 'missing', {
|
||||
text: 'folder input'
|
||||
})
|
||||
).resolves.toMatchObject({ accepted: true, bytesWritten: 12 })
|
||||
await runtime.activateMobileSessionTab(`id:${folderKey}`, TAB_ID)
|
||||
|
||||
expect(spawn).toHaveBeenCalledOnce()
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sessionId: ptyId, worktreeId: folderKey })
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['activate', 'close'] as const)(
|
||||
'rejects a folder tab %s when deletion races PTY inventory',
|
||||
async (operation) => {
|
||||
|
||||
@@ -28259,7 +28259,88 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('reattaches an inventory-restored serve terminal once before accepting input', async () => {
|
||||
const ptyId = 'serve-restarted-git-pty'
|
||||
const incarnationId = '91919191-9191-4919-8919-919191919191'
|
||||
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(
|
||||
makeWorkspaceSessionWithHeadlessTerminal({
|
||||
tabsByWorktree: {
|
||||
[TEST_WORKTREE_ID]: [
|
||||
{
|
||||
id: 'host-tab',
|
||||
ptyId,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Restored Git Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: ptyId })
|
||||
},
|
||||
terminalPtyIncarnationsByPaneKey: {
|
||||
[makePaneKey('host-tab', HEADLESS_LEAF_ID)]: incarnationId
|
||||
}
|
||||
})
|
||||
)
|
||||
let attached = false
|
||||
const spawn = vi.fn(async () => {
|
||||
attached = true
|
||||
return { id: ptyId, incarnationId }
|
||||
})
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => attached,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => [
|
||||
{
|
||||
id: ptyId,
|
||||
cwd: TEST_WORKTREE_PATH,
|
||||
title: 'Restored Git Terminal',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
terminalHandle: 'term_restarted_git',
|
||||
incarnationId
|
||||
}
|
||||
]
|
||||
})
|
||||
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
|
||||
|
||||
const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
const listedTab = listed.tabs[0]
|
||||
expect(listedTab).toMatchObject({ status: 'ready', terminal: 'term_restarted_git' })
|
||||
await expect(
|
||||
runtime.sendTerminal(listedTab?.type === 'terminal' ? listedTab.terminal! : 'missing', {
|
||||
text: 'before attach'
|
||||
})
|
||||
).rejects.toThrow('terminal_not_writable')
|
||||
|
||||
const activated = await runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab')
|
||||
const activatedTab = activated.tabs[0]
|
||||
await expect(
|
||||
runtime.sendTerminal(activatedTab?.type === 'terminal' ? activatedTab.terminal! : 'missing', {
|
||||
text: 'after attach'
|
||||
})
|
||||
).resolves.toMatchObject({ accepted: true, bytesWritten: 12 })
|
||||
await runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab')
|
||||
|
||||
expect(spawn).toHaveBeenCalledOnce()
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: ptyId,
|
||||
tabId: 'host-tab',
|
||||
leafId: HEADLESS_LEAF_ID,
|
||||
worktreeId: TEST_WORKTREE_ID
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('reattaches hydrated SSH headless terminals with the persisted relay identity', async () => {
|
||||
const incarnationId = '92929292-9292-4929-8929-929292929292'
|
||||
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(
|
||||
makeWorkspaceSessionWithHeadlessTerminal({
|
||||
tabsByWorktree: {
|
||||
@@ -28280,6 +28361,9 @@ describe('OrcaRuntimeService', () => {
|
||||
'host-tab': makeHeadlessTerminalLayout({
|
||||
[HEADLESS_LEAF_ID]: 'ssh:ssh-1@@relay-pty'
|
||||
})
|
||||
},
|
||||
terminalPtyIncarnationsByPaneKey: {
|
||||
[makePaneKey('host-tab', HEADLESS_LEAF_ID)]: incarnationId
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -28296,12 +28380,23 @@ describe('OrcaRuntimeService', () => {
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => []
|
||||
listProcesses: async () => [
|
||||
{
|
||||
id: 'ssh:ssh-1@@relay-pty',
|
||||
cwd: TEST_WORKTREE_PATH,
|
||||
title: 'Remote Terminal',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
terminalHandle: 'term_restarted_ssh',
|
||||
incarnationId
|
||||
}
|
||||
]
|
||||
})
|
||||
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
|
||||
|
||||
await runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab')
|
||||
await runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab')
|
||||
|
||||
expect(spawn).toHaveBeenCalledOnce()
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
connectionId: 'ssh-1',
|
||||
|
||||
@@ -8090,9 +8090,12 @@ export class OrcaRuntimeService {
|
||||
// their tab id, so focusing the renderer would silently no-op.
|
||||
// Phone-local activation also needs this path for inactive restored tabs:
|
||||
// desktop focus is intentionally suppressed, but the PTY still must exist.
|
||||
// PTY inventory proves liveness, not attachment to this runtime generation.
|
||||
const shouldMaterializePendingTerminal =
|
||||
publicTab?.type === 'terminal' &&
|
||||
publicTab.status !== 'ready' &&
|
||||
(publicTab.status !== 'ready' ||
|
||||
(this.hasServeOrSshOwnedBinding(tab) &&
|
||||
!this.hasLiveRuntimeSessionOwnedPtyBinding(worktreeId, tab))) &&
|
||||
(!targetsHost ||
|
||||
!this.notifier?.focusTerminal ||
|
||||
this.shouldMaterializeHeadlessMobileSessionTab(snapshot!, tab))
|
||||
|
||||
Reference in New Issue
Block a user