mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 00:27:39 +00:00
Add multi-repo folder workspaces (v1) (#5172)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
/* eslint-disable max-lines -- Why: runtime behavior is stateful and cross-cutting, so these tests stay in one file to preserve the end-to-end invariants around handles, waits, and graph sync. */
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { EventEmitter } from 'events'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { lstat, mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { ipcMain } from 'electron'
|
||||
import type {
|
||||
FolderWorkspace,
|
||||
ProjectGroup,
|
||||
TerminalLayoutSnapshot,
|
||||
WorktreeLineage,
|
||||
WorktreeMeta,
|
||||
@@ -506,6 +509,10 @@ const TEST_REPO_ID = 'repo-1'
|
||||
const TEST_REPO_PATH = '/tmp/repo'
|
||||
const TEST_WORKTREE_PATH = '/tmp/worktree-a'
|
||||
const TEST_WORKTREE_ID = `${TEST_REPO_ID}::${TEST_WORKTREE_PATH}`
|
||||
const TEST_FOLDER_PROJECT_GROUP_ID = 'folder-project-group-1'
|
||||
const TEST_FOLDER_WORKSPACE_ID = 'folder-workspace-1'
|
||||
const TEST_FOLDER_WORKSPACE_KEY = `folder:${TEST_FOLDER_WORKSPACE_ID}`
|
||||
const TEST_FOLDER_WORKSPACE_PATH = '/tmp/platform'
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
||||
const HEADLESS_LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const HEADLESS_SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222'
|
||||
@@ -623,6 +630,52 @@ function createRuntime(): OrcaRuntimeService {
|
||||
return new OrcaRuntimeService(store)
|
||||
}
|
||||
|
||||
function makeFolderProjectGroup(overrides: Partial<ProjectGroup> = {}): ProjectGroup {
|
||||
return {
|
||||
id: TEST_FOLDER_PROJECT_GROUP_ID,
|
||||
name: 'Platform',
|
||||
parentPath: TEST_FOLDER_WORKSPACE_PATH,
|
||||
parentGroupId: null,
|
||||
createdFrom: 'folder-scan',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeFolderWorkspace(overrides: Partial<FolderWorkspace> = {}): FolderWorkspace {
|
||||
return {
|
||||
...overrides,
|
||||
id: overrides.id ?? TEST_FOLDER_WORKSPACE_ID,
|
||||
projectGroupId: overrides.projectGroupId ?? TEST_FOLDER_PROJECT_GROUP_ID,
|
||||
name: overrides.name ?? 'Refund fix',
|
||||
folderPath: overrides.folderPath ?? TEST_FOLDER_WORKSPACE_PATH,
|
||||
linkedTask: overrides.linkedTask ?? null,
|
||||
comment: overrides.comment ?? '',
|
||||
isArchived: overrides.isArchived ?? false,
|
||||
isUnread: overrides.isUnread ?? false,
|
||||
isPinned: overrides.isPinned ?? false,
|
||||
sortOrder: overrides.sortOrder ?? 0,
|
||||
lastActivityAt: overrides.lastActivityAt ?? 1,
|
||||
createdAt: overrides.createdAt ?? 1,
|
||||
updatedAt: overrides.updatedAt ?? 1
|
||||
}
|
||||
}
|
||||
|
||||
function createFolderWorkspaceRuntimeStore(
|
||||
folderWorkspace: FolderWorkspace = makeFolderWorkspace(),
|
||||
projectGroup: ProjectGroup = makeFolderProjectGroup()
|
||||
) {
|
||||
return {
|
||||
...store,
|
||||
getProjectGroups: () => [projectGroup],
|
||||
getFolderWorkspaces: () => [folderWorkspace]
|
||||
}
|
||||
}
|
||||
|
||||
function makeRpcRequest(method: string, params?: unknown): RpcRequest {
|
||||
return { id: 'req-1', authToken: 'tok', method, params }
|
||||
}
|
||||
@@ -3952,6 +4005,86 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ label: 'canonical folder workspace selector', selector: TEST_FOLDER_WORKSPACE_KEY },
|
||||
{ label: 'id-prefixed folder workspace selector', selector: `id:${TEST_FOLDER_WORKSPACE_KEY}` }
|
||||
])('creates background terminal sessions for a $label', async ({ selector }) => {
|
||||
const folderPath = await mkdtemp(join(tmpdir(), 'orca-runtime-folder-workspace-'))
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-folder' })
|
||||
const folderWorkspace = makeFolderWorkspace({ folderPath })
|
||||
const projectGroup = makeFolderProjectGroup({ parentPath: folderPath })
|
||||
const runtime = new OrcaRuntimeService(
|
||||
createFolderWorkspaceRuntimeStore(folderWorkspace, projectGroup) as never
|
||||
)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
|
||||
await expect(
|
||||
runtime.createTerminal(selector, {
|
||||
command: 'codex',
|
||||
title: 'multi-repo worker'
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
worktreeId: TEST_FOLDER_WORKSPACE_KEY,
|
||||
title: 'multi-repo worker',
|
||||
surface: 'background'
|
||||
})
|
||||
|
||||
const spawnCall = spawn.mock.calls[0]?.[0] as
|
||||
| { cwd?: string; env?: Record<string, string>; worktreeId?: string }
|
||||
| undefined
|
||||
const spawnedEnv = spawnCall?.env ?? {}
|
||||
expect(spawnCall).toMatchObject({
|
||||
cwd: folderPath,
|
||||
worktreeId: TEST_FOLDER_WORKSPACE_KEY
|
||||
})
|
||||
expectStablePaneKeyEnv(spawnedEnv)
|
||||
expect(spawnedEnv.ORCA_WORKSPACE_ID).toBe(TEST_FOLDER_WORKSPACE_KEY)
|
||||
expect(spawnedEnv.ORCA_PROJECT_GROUP_ID).toBe(TEST_FOLDER_PROJECT_GROUP_ID)
|
||||
expect(spawnedEnv.ORCA_WORKSPACE_ROOT).toBe(folderPath)
|
||||
expect(spawnedEnv.ORCA_WORKTREE_ID).toBe(TEST_FOLDER_WORKSPACE_KEY)
|
||||
})
|
||||
|
||||
it('rejects folder workspace terminal creation when the backing path is missing', async () => {
|
||||
const missingPath = join(tmpdir(), `orca-missing-folder-workspace-${randomUUID()}`)
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-folder' })
|
||||
const folderWorkspace = makeFolderWorkspace({ folderPath: missingPath })
|
||||
const projectGroup = makeFolderProjectGroup({ parentPath: missingPath })
|
||||
const runtime = new OrcaRuntimeService(
|
||||
createFolderWorkspaceRuntimeStore(folderWorkspace, projectGroup) as never
|
||||
)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
|
||||
await expect(runtime.createTerminal(TEST_FOLDER_WORKSPACE_KEY)).rejects.toThrow(
|
||||
'folder_workspace_path_missing'
|
||||
)
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects folder workspace folderPath updates when the new path is missing', async () => {
|
||||
const missingPath = join(tmpdir(), `orca-missing-folder-update-${randomUUID()}`)
|
||||
const folderWorkspace = makeFolderWorkspace()
|
||||
const runtimeStore = {
|
||||
...createFolderWorkspaceRuntimeStore(folderWorkspace),
|
||||
updateFolderWorkspace: vi.fn()
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
|
||||
await expect(
|
||||
runtime.updateFolderWorkspace(TEST_FOLDER_WORKSPACE_ID, { folderPath: missingPath })
|
||||
).rejects.toThrow('folder_workspace_path_missing')
|
||||
expect(runtimeStore.updateFolderWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('enables Claude Agent Teams only for direct Claude launches when configured in-process', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' })
|
||||
const runtimeStore = {
|
||||
@@ -4166,6 +4299,84 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('splits folder workspace pty-backed terminal sessions with folder cwd and env', async () => {
|
||||
const folderPath = await mkdtemp(join(tmpdir(), 'orca-runtime-folder-split-'))
|
||||
const spawn = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 'pty-folder-source' })
|
||||
.mockResolvedValueOnce({ id: 'pty-folder-split' })
|
||||
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-folder' })
|
||||
const folderWorkspace = makeFolderWorkspace({ folderPath })
|
||||
const projectGroup = makeFolderProjectGroup({ parentPath: folderPath })
|
||||
const runtime = new OrcaRuntimeService(
|
||||
createFolderWorkspaceRuntimeStore(folderWorkspace, projectGroup) as never
|
||||
)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.setNotifier({
|
||||
worktreesChanged: vi.fn(),
|
||||
reposChanged: vi.fn(),
|
||||
activateWorktree: vi.fn(),
|
||||
createTerminal: vi.fn(),
|
||||
revealTerminalSession,
|
||||
splitTerminal: vi.fn(),
|
||||
renameTerminal: vi.fn(),
|
||||
focusTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: vi.fn(),
|
||||
terminalDriverChanged: vi.fn()
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
|
||||
|
||||
const { handle } = await runtime.createTerminal(TEST_FOLDER_WORKSPACE_KEY)
|
||||
const sourceCall = spawn.mock.calls[0]?.[0] as
|
||||
| { cwd?: string; env?: Record<string, string>; worktreeId?: string }
|
||||
| undefined
|
||||
const sourceEnv = sourceCall?.env ?? {}
|
||||
const sourceLeafId = sourceEnv.ORCA_PANE_KEY.slice(`${sourceEnv.ORCA_TAB_ID}:`.length)
|
||||
|
||||
await expect(runtime.splitTerminal(handle, { direction: 'vertical' })).resolves.toMatchObject({
|
||||
handle: expect.stringMatching(/^term_/),
|
||||
tabId: sourceEnv.ORCA_TAB_ID,
|
||||
paneRuntimeId: -1
|
||||
})
|
||||
|
||||
const splitCall = spawn.mock.calls[1]?.[0] as
|
||||
| { cwd?: string; env?: Record<string, string>; worktreeId?: string }
|
||||
| undefined
|
||||
const splitEnv = splitCall?.env ?? {}
|
||||
const splitLeafId = splitEnv.ORCA_PANE_KEY.slice(`${sourceEnv.ORCA_TAB_ID}:`.length)
|
||||
expect(sourceCall).toMatchObject({
|
||||
cwd: folderPath,
|
||||
worktreeId: TEST_FOLDER_WORKSPACE_KEY
|
||||
})
|
||||
expect(splitCall).toMatchObject({
|
||||
cwd: folderPath,
|
||||
worktreeId: TEST_FOLDER_WORKSPACE_KEY
|
||||
})
|
||||
expectStablePaneKeyEnv(splitEnv)
|
||||
expect(splitEnv.ORCA_TAB_ID).toBe(sourceEnv.ORCA_TAB_ID)
|
||||
expect(splitEnv.ORCA_WORKSPACE_ID).toBe(TEST_FOLDER_WORKSPACE_KEY)
|
||||
expect(splitEnv.ORCA_PROJECT_GROUP_ID).toBe(TEST_FOLDER_PROJECT_GROUP_ID)
|
||||
expect(splitEnv.ORCA_WORKSPACE_ROOT).toBe(folderPath)
|
||||
expect(splitEnv.ORCA_WORKTREE_ID).toBe(TEST_FOLDER_WORKSPACE_KEY)
|
||||
expect(revealTerminalSession).toHaveBeenLastCalledWith(TEST_FOLDER_WORKSPACE_KEY, {
|
||||
ptyId: 'pty-folder-split',
|
||||
title: null,
|
||||
activate: true,
|
||||
tabId: sourceEnv.ORCA_TAB_ID,
|
||||
leafId: splitLeafId,
|
||||
splitFromLeafId: sourceLeafId,
|
||||
splitDirection: 'vertical'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns a background handle when inactive tab adoption fails after spawn', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' })
|
||||
const revealTerminalSession = vi.fn().mockRejectedValue(new Error('Renderer timed out'))
|
||||
|
||||
Reference in New Issue
Block a user