mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
refactor(automations): main creates an automation workspace's setup and default tabs (#28130)
* refactor(automations): main provisions an automation workspace's setup and default tabs An automation's create handed setup and default tabs back to the window, which launched them through its own background-terminal code. Main now provisions them through the same provisionWorktreeTerminals the CLI create uses, and returns neither, so setup runs once and the window's copy is deleted. * fix(worktree): translate the Setup tab title wherever it is created Main had no copy of the automation path's translated title, and the window and CLI creates used the English literal. All three now read worktree.setupTabTitle through the existing main and renderer translators. The create path's runtime is required, since every production caller passes it. * fix(worktree): keep the Setup title in English; runtime code cannot load main's translator main-i18n imports Electron, which orcad and the runtime must not load. The title stays the English literal the window and CLI creates already use. * refactor(worktree): drop the runtime check the required runtime made dead
This commit is contained in:
@@ -333,7 +333,6 @@
|
||||
2 src/renderer/src/lib/launch-agent-web-host-tab.ts
|
||||
2 src/renderer/src/lib/launch-ai-vault-session.ts
|
||||
1 src/renderer/src/lib/launch-structured-agent-session.ts
|
||||
1 src/renderer/src/lib/launch-worktree-background-terminals.ts
|
||||
1 src/renderer/src/lib/linear-issue-url-lookup.ts
|
||||
1 src/renderer/src/lib/local-preflight-context.ts
|
||||
3 src/renderer/src/lib/open-agent-message-sender.ts
|
||||
|
||||
@@ -9384,7 +9384,6 @@
|
||||
"src/renderer/src/lib/launch-work-item-direct-agent.test.ts": 163,
|
||||
"src/renderer/src/lib/launch-work-item-direct-messages.test.ts": 48,
|
||||
"src/renderer/src/lib/launch-work-item-direct.test.ts": 1033,
|
||||
"src/renderer/src/lib/launch-worktree-background-terminals.test.ts": 848,
|
||||
"src/renderer/src/lib/lazy-chunk-recovery-reload.test.ts": 200,
|
||||
"src/renderer/src/lib/lazy-with-retry.never-landed-reload.test.ts": 172,
|
||||
"src/renderer/src/lib/lazy-with-retry.test.ts": 232,
|
||||
|
||||
@@ -306,7 +306,7 @@ export function recordWorkspaceLineageForCreatedWorktree(
|
||||
}
|
||||
|
||||
async function spawnLocalStartupAndSetupTerminals(args: {
|
||||
runtime: OrcaRuntimeService | undefined
|
||||
runtime: OrcaRuntimeService
|
||||
worktree: Worktree
|
||||
startup: CreateWorktreeArgs['startup']
|
||||
setup: CreateWorktreeResult['setup']
|
||||
@@ -315,7 +315,7 @@ async function spawnLocalStartupAndSetupTerminals(args: {
|
||||
createdWithAgent: CreateWorktreeArgs['createdWithAgent']
|
||||
}): Promise<StagedStartupResult> {
|
||||
const { runtime, worktree, startup, setup, defaultTabs, settings, createdWithAgent } = args
|
||||
if (!runtime || !startup || defaultTabs?.tabs.length) {
|
||||
if (!startup || defaultTabs?.tabs.length) {
|
||||
return { didSpawnSetup: false }
|
||||
}
|
||||
|
||||
@@ -953,7 +953,7 @@ export function createLocalWorktree(
|
||||
repo: Repo,
|
||||
store: Store,
|
||||
mainWindow: BrowserWindow,
|
||||
runtime?: OrcaRuntimeService,
|
||||
runtime: OrcaRuntimeService,
|
||||
timing: WorktreeCreateTimingRecorder = createWorktreeCreateTimingRecorder()
|
||||
): Promise<CreateWorktreeResult> {
|
||||
// Why a holder fired in `finally`: consuming a prepared checkout leaves the pool one short, so a
|
||||
@@ -976,7 +976,7 @@ async function performLocalWorktreeCreate(
|
||||
mainWindow: BrowserWindow,
|
||||
rearm: PreparationRearmHolder,
|
||||
timing: WorktreeCreateTimingRecorder,
|
||||
runtime?: OrcaRuntimeService
|
||||
runtime: OrcaRuntimeService
|
||||
): Promise<CreateWorktreeResult> {
|
||||
const settings = store.getSettings()
|
||||
const worktreePathSettings = getWorktreePathSettings(
|
||||
@@ -1691,6 +1691,24 @@ async function performLocalWorktreeCreate(
|
||||
})
|
||||
)
|
||||
|
||||
// Why: an automation's terminals start in main, so the window is handed none to launch.
|
||||
const provisionedByHost = Boolean(args.automationProvenance)
|
||||
if (provisionedByHost && (setup || defaultTabs?.tabs.length)) {
|
||||
void runtime.provisionManagedWorktreeTerminals(
|
||||
{
|
||||
worktreeSelector: `id:${worktree.id}`,
|
||||
worktreeId: worktree.id,
|
||||
worktreePath: worktree.path,
|
||||
...(setup ? { setup } : {}),
|
||||
...(defaultTabs ? { defaultTabs } : {}),
|
||||
hasStartupTerminal: false,
|
||||
setupCommandPlatform: getSetupRunnerCommandPlatformForLaunch(setup, 'posix'),
|
||||
surfaceOwner: false
|
||||
},
|
||||
worktree
|
||||
)
|
||||
}
|
||||
|
||||
notifyWorktreesChanged(mainWindow, repo.id)
|
||||
return {
|
||||
worktree: {
|
||||
@@ -1702,12 +1720,16 @@ async function performLocalWorktreeCreate(
|
||||
},
|
||||
...(worktreeLineage ? { lineage: worktreeLineage } : {}),
|
||||
...(workspaceLineage ? { workspaceLineage } : {}),
|
||||
...(stagedStartup.activationSetup
|
||||
? { setup: stagedStartup.activationSetup }
|
||||
: setup && !stagedStartup.didSpawnSetup
|
||||
? { setup }
|
||||
: {}),
|
||||
...(defaultTabs ? { defaultTabs } : {}),
|
||||
...(provisionedByHost
|
||||
? {}
|
||||
: {
|
||||
...(stagedStartup.activationSetup
|
||||
? { setup: stagedStartup.activationSetup }
|
||||
: setup && !stagedStartup.didSpawnSetup
|
||||
? { setup }
|
||||
: {}),
|
||||
...(defaultTabs ? { defaultTabs } : {})
|
||||
}),
|
||||
...(addResult.localBaseRefRefresh
|
||||
? { localBaseRefRefresh: addResult.localBaseRefRefresh }
|
||||
: {}),
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
listWorktreesMock,
|
||||
getEffectiveHooksMock,
|
||||
getDefaultTabsLaunchMock,
|
||||
createSetupRunnerScriptMock,
|
||||
shouldRunSetupForCreateMock
|
||||
} from './worktrees-test-module-mocks'
|
||||
import { handlers, setupWorktreeHandlers } from './worktrees-test-harness'
|
||||
import { createdWorktreeList } from './worktrees-test-fixtures'
|
||||
|
||||
vi.mock('electron', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).electronModuleMock()
|
||||
)
|
||||
vi.mock('../git/worktree', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock()
|
||||
)
|
||||
vi.mock('../git/runner', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).gitRunnerModuleMock()
|
||||
)
|
||||
vi.mock('../git/repo', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).gitRepoModuleMock()
|
||||
)
|
||||
vi.mock('../git/git-username', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
resolveLocalGitUsername: (await import('./worktrees-test-module-mocks'))
|
||||
.resolveLocalGitUsernameMock
|
||||
}))
|
||||
vi.mock('../github/client', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).githubClientModuleMock()
|
||||
)
|
||||
vi.mock('../source-control/hosted-review', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).hostedReviewModuleMock()
|
||||
)
|
||||
vi.mock('../providers/ssh-filesystem-dispatch', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock()
|
||||
)
|
||||
vi.mock('./worktree-symlinks', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
|
||||
)
|
||||
vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock())
|
||||
vi.mock('../setup-runner-script-text', async (importOriginal) =>
|
||||
(await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock(
|
||||
await importOriginal<Record<string, unknown>>()
|
||||
)
|
||||
)
|
||||
vi.mock('../worktree-runner-script', async (importOriginal) =>
|
||||
(await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock(
|
||||
await importOriginal<Record<string, unknown>>()
|
||||
)
|
||||
)
|
||||
vi.mock('../effective-hook-config', async (importOriginal) =>
|
||||
(await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock(
|
||||
await importOriginal<Record<string, unknown>>()
|
||||
)
|
||||
)
|
||||
vi.mock('../setup-hook-env-vars', async (importOriginal) =>
|
||||
(await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock(
|
||||
await importOriginal<Record<string, unknown>>()
|
||||
)
|
||||
)
|
||||
vi.mock('./worktree-logic', async (importOriginal) =>
|
||||
(await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock(
|
||||
await importOriginal<Record<string, unknown>>()
|
||||
)
|
||||
)
|
||||
vi.mock('../terminal-history-deletion', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock()
|
||||
)
|
||||
vi.mock('../ports/advertised-url-watcher', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).advertisedUrlWatcherModuleMock()
|
||||
)
|
||||
vi.mock('../workspace-cleanup-scan-snapshot', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).workspaceCleanupScanSnapshotModuleMock()
|
||||
)
|
||||
vi.mock('../workspace-space-analysis-snapshot', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).workspaceSpaceAnalysisSnapshotModuleMock()
|
||||
)
|
||||
vi.mock('../workspace-cleanup-removal-snapshot-prune', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).workspaceCleanupRemovalSnapshotPruneModuleMock()
|
||||
)
|
||||
vi.mock('../runtime/worktree-teardown', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).worktreeTeardownModuleMock()
|
||||
)
|
||||
vi.mock('./pty/provider/registry', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).ptyRegistryModuleMock()
|
||||
)
|
||||
vi.mock('./pty/provider/state-cleanup', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).ptyStateCleanupModuleMock()
|
||||
)
|
||||
|
||||
vi.mock('../automations/workspace-provenance', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
resolveAutomationWorkspaceProvenance: ({ request }: { request?: unknown }) =>
|
||||
request ? { kind: 'created-by-automation', automationId: 'automation-1' } : undefined,
|
||||
finishAutomationWorkspaceProvenanceRequest: () => {},
|
||||
releaseAutomationWorkspaceProvenanceRequest: () => {}
|
||||
}))
|
||||
|
||||
const automationProvenanceRequest = {
|
||||
automationId: 'automation-1',
|
||||
automationRunId: 'run-1',
|
||||
dispatchToken: 'token-1',
|
||||
createRequestId: 'create-1'
|
||||
}
|
||||
const defaultTabs = { tabs: [{ title: 'Dev', command: 'pnpm dev' }], runCommands: true }
|
||||
|
||||
describe('automation creates: main provisions setup and default tabs', () => {
|
||||
let provision: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
const runtime = setupWorktreeHandlers()
|
||||
provision = vi.fn().mockResolvedValue({ setupSpawned: true, setupTerminalHandle: 'term-setup' })
|
||||
Object.assign(runtime, { provisionManagedWorktreeTerminals: provision })
|
||||
listWorktreesMock.mockResolvedValue(createdWorktreeList)
|
||||
getEffectiveHooksMock.mockReturnValue({ scripts: { setup: 'pnpm worktree:setup' } })
|
||||
shouldRunSetupForCreateMock.mockReturnValue(true)
|
||||
getDefaultTabsLaunchMock.mockReturnValue(defaultTabs)
|
||||
})
|
||||
|
||||
it('provisions once in main and hands the window nothing to launch', async () => {
|
||||
const result = await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'improve-dashboard',
|
||||
setupDecision: 'run',
|
||||
automationProvenanceRequest
|
||||
})
|
||||
|
||||
expect(result).not.toHaveProperty('setup')
|
||||
expect(result).not.toHaveProperty('defaultTabs')
|
||||
expect(provision).toHaveBeenCalledOnce()
|
||||
expect(provision).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
worktreePath: '/workspace/improve-dashboard',
|
||||
setup: expect.objectContaining({
|
||||
runnerScriptPath: '/workspace/repo/.git/orca/setup-runner.sh'
|
||||
}),
|
||||
defaultTabs,
|
||||
hasStartupTerminal: false,
|
||||
setupCommandPlatform: 'posix',
|
||||
surfaceOwner: false
|
||||
}),
|
||||
expect.objectContaining({ path: '/workspace/improve-dashboard' })
|
||||
)
|
||||
})
|
||||
|
||||
// Same pick the window made: the runner's path decides, a bare path falls back to posix.
|
||||
it.each([
|
||||
{ runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.cmd', platform: 'windows' },
|
||||
{ runnerScriptPath: '/home/me/repo/.git/orca/setup-runner.sh', platform: 'posix' }
|
||||
])(
|
||||
'runs a $platform runner with the $platform command',
|
||||
async ({ runnerScriptPath, platform }) => {
|
||||
createSetupRunnerScriptMock.mockReturnValueOnce({ runnerScriptPath, envVars: {} })
|
||||
|
||||
await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'improve-dashboard',
|
||||
setupDecision: 'run',
|
||||
automationProvenanceRequest
|
||||
})
|
||||
|
||||
expect(provision).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ setupCommandPlatform: platform }),
|
||||
expect.anything()
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('leaves a create from the app window to the window', async () => {
|
||||
const result = await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'improve-dashboard',
|
||||
setupDecision: 'run'
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ setup: expect.anything(), defaultTabs })
|
||||
expect(provision).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -312,7 +312,7 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM
|
||||
return deliverWorktreeStartupFollowup(this.getWorktreeStartupReadinessHost(), handle, followup)
|
||||
}
|
||||
|
||||
protected async provisionManagedWorktreeTerminals(
|
||||
async provisionManagedWorktreeTerminals(
|
||||
args: {
|
||||
worktreeSelector: string
|
||||
worktreeId: string
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { launchWorktreeBackgroundTerminals } from '@/lib/launch-worktree-background-terminals'
|
||||
import { useAppStore } from '@/store'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
@@ -244,17 +243,6 @@ export async function prepareAutomationDispatchWorkspace(args: {
|
||||
}
|
||||
context.workspaceId = worktree.id
|
||||
context.workspaceDisplayName = worktree.displayName
|
||||
if (createResult?.setup || createResult?.defaultTabs) {
|
||||
void launchWorktreeBackgroundTerminals({
|
||||
worktreeId: worktree.id,
|
||||
setup: createResult.setup,
|
||||
defaultTabs: createResult.defaultTabs
|
||||
}).catch((error) => {
|
||||
// Why: setup/defaultTabs match normal worktree creation: they are
|
||||
// best-effort terminal work and must not block the automation agent.
|
||||
console.warn('[automations] Failed to launch workspace setup/default tabs:', error)
|
||||
})
|
||||
}
|
||||
return worktree
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { countUnchangedObserverHistoryReads } from './automation-dispatch-observ
|
||||
const mockDispatchEvent = vi.fn()
|
||||
|
||||
const mockLaunchAgentBackgroundSession = vi.fn()
|
||||
const mockLaunchWorktreeBackgroundTerminals = vi.fn()
|
||||
const mockFindReusableAutomationSession = vi.fn()
|
||||
const mockObserveExistingAutomationSession = vi.fn()
|
||||
const mockSubmitPromptToAgentPty = vi.fn()
|
||||
@@ -25,11 +24,6 @@ const mockStoreSubscribe = vi.fn((listener: () => void) => {
|
||||
return () => {}
|
||||
})
|
||||
|
||||
const setupLaunch = {
|
||||
runnerScriptPath: '/tmp/setup.sh',
|
||||
envVars: { ORCA_WORKTREE_PATH: '/repo/worktree' }
|
||||
}
|
||||
|
||||
const createdWorktree = {
|
||||
id: 'wt-created',
|
||||
repoId: 'repo-1',
|
||||
@@ -130,10 +124,6 @@ vi.mock('@/lib/launch-agent-background-session', () => ({
|
||||
launchAgentBackgroundSession: mockLaunchAgentBackgroundSession
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/launch-worktree-background-terminals', () => ({
|
||||
launchWorktreeBackgroundTerminals: mockLaunchWorktreeBackgroundTerminals
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/agent-paste-draft', () => ({
|
||||
submitPromptToAgentPty: mockSubmitPromptToAgentPty
|
||||
}))
|
||||
@@ -202,8 +192,7 @@ describe('useAutomationDispatchEvents setup launch', () => {
|
||||
latestStoreSubscriber = null
|
||||
state.allWorktrees.mockReturnValue([])
|
||||
state.getKnownWorktreeById.mockReturnValue(undefined)
|
||||
mockCreateWorktree.mockResolvedValue({ worktree: createdWorktree, setup: setupLaunch })
|
||||
mockLaunchWorktreeBackgroundTerminals.mockResolvedValue(undefined)
|
||||
mockCreateWorktree.mockResolvedValue({ worktree: createdWorktree })
|
||||
mockLaunchAgentBackgroundSession.mockResolvedValue({
|
||||
tabId: 'agent-tab',
|
||||
paneKey: 'agent-tab:7c6fb4e5-3bf1-4ff4-8259-03f7ae81c40d',
|
||||
@@ -248,84 +237,6 @@ describe('useAutomationDispatchEvents setup launch', () => {
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('starts setup terminal launch without waiting before launching the automation agent', async () => {
|
||||
const order: string[] = []
|
||||
let finishSetupLaunch: (() => void) | null = null
|
||||
mockLaunchWorktreeBackgroundTerminals.mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishSetupLaunch = () => {
|
||||
order.push('setup')
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
)
|
||||
mockLaunchAgentBackgroundSession.mockImplementation(async () => {
|
||||
order.push('agent')
|
||||
return { tabId: 'agent-tab', ptyId: 'agent-pty', startupPlan: {} }
|
||||
})
|
||||
|
||||
await registerAndDispatch()
|
||||
|
||||
expect(mockCreateWorktree).toHaveBeenCalled()
|
||||
expect(mockCreateWorktree.mock.calls[0][3]).toBe('run')
|
||||
expect(mockLaunchWorktreeBackgroundTerminals).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-created',
|
||||
setup: setupLaunch,
|
||||
defaultTabs: undefined
|
||||
})
|
||||
expect(state.setActiveView).not.toHaveBeenCalled()
|
||||
expect(state.setActiveWorktree).not.toHaveBeenCalled()
|
||||
expect(mockLaunchAgentBackgroundSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
worktreeId: 'wt-created',
|
||||
prompt: 'run this'
|
||||
})
|
||||
)
|
||||
expect(order).toEqual(['agent'])
|
||||
expect(finishSetupLaunch).not.toBeNull()
|
||||
const completeSetupLaunch = finishSetupLaunch as unknown as () => void
|
||||
completeSetupLaunch()
|
||||
await Promise.resolve()
|
||||
expect(order).toEqual(['agent', 'setup'])
|
||||
expect(mockMarkDispatchResult).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runId: 'run-1',
|
||||
status: 'dispatched',
|
||||
workspaceId: 'wt-created',
|
||||
terminalSessionId: 'agent-tab'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('launches setup and default tabs without activating the created worktree', async () => {
|
||||
const defaultTabs = {
|
||||
tabs: [{ title: 'Dev', command: 'pnpm dev' }],
|
||||
runCommands: true
|
||||
}
|
||||
mockCreateWorktree.mockResolvedValue({
|
||||
worktree: createdWorktree,
|
||||
setup: setupLaunch,
|
||||
defaultTabs
|
||||
})
|
||||
|
||||
await registerAndDispatch()
|
||||
|
||||
expect(mockLaunchWorktreeBackgroundTerminals).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-created',
|
||||
setup: setupLaunch,
|
||||
defaultTabs
|
||||
})
|
||||
expect(state.setActiveView).not.toHaveBeenCalled()
|
||||
expect(state.setActiveWorktree).not.toHaveBeenCalled()
|
||||
expect(mockLaunchAgentBackgroundSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
worktreeId: 'wt-created',
|
||||
prompt: 'run this'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults legacy automations without a setup choice to skipping setup', async () => {
|
||||
await registerAndDispatch(makeAutomation({ setupDecision: undefined }))
|
||||
|
||||
@@ -346,32 +257,6 @@ describe('useAutomationDispatchEvents setup launch', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps launching the agent when background setup terminal launch fails', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
mockLaunchWorktreeBackgroundTerminals.mockRejectedValue(new Error('tab launch failed'))
|
||||
|
||||
try {
|
||||
await registerAndDispatch()
|
||||
} finally {
|
||||
warnSpy.mockRestore()
|
||||
}
|
||||
|
||||
expect(mockLaunchAgentBackgroundSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
worktreeId: 'wt-created',
|
||||
prompt: 'run this'
|
||||
})
|
||||
)
|
||||
expect(mockMarkDispatchResult).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runId: 'run-1',
|
||||
status: 'dispatched',
|
||||
workspaceId: 'wt-created',
|
||||
terminalSessionId: 'agent-tab'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not rerun setup for existing-worktree automations', async () => {
|
||||
const existingWorktree = {
|
||||
id: 'wt-existing',
|
||||
@@ -390,7 +275,6 @@ describe('useAutomationDispatchEvents setup launch', () => {
|
||||
)
|
||||
|
||||
expect(mockCreateWorktree).not.toHaveBeenCalled()
|
||||
expect(mockLaunchWorktreeBackgroundTerminals).not.toHaveBeenCalled()
|
||||
expect(mockLaunchAgentBackgroundSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
worktreeId: 'wt-existing',
|
||||
|
||||
@@ -832,13 +832,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"worktree": {
|
||||
"background": {
|
||||
"terminals": {
|
||||
"setupTitle": "Setup"
|
||||
}
|
||||
}
|
||||
},
|
||||
"work": {
|
||||
"item": {
|
||||
"direct": {
|
||||
|
||||
@@ -649,13 +649,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"worktree": {
|
||||
"background": {
|
||||
"terminals": {
|
||||
"setupTitle": "Configuración"
|
||||
}
|
||||
}
|
||||
},
|
||||
"work": {
|
||||
"item": {
|
||||
"direct": {
|
||||
|
||||
@@ -750,13 +750,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"worktree": {
|
||||
"background": {
|
||||
"terminals": {
|
||||
"setupTitle": "Configuration"
|
||||
}
|
||||
}
|
||||
},
|
||||
"work": {
|
||||
"item": {
|
||||
"direct": {
|
||||
|
||||
@@ -649,13 +649,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"worktree": {
|
||||
"background": {
|
||||
"terminals": {
|
||||
"setupTitle": "設定"
|
||||
}
|
||||
}
|
||||
},
|
||||
"work": {
|
||||
"item": {
|
||||
"direct": {
|
||||
|
||||
@@ -649,13 +649,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"worktree": {
|
||||
"background": {
|
||||
"terminals": {
|
||||
"setupTitle": "설정"
|
||||
}
|
||||
}
|
||||
},
|
||||
"work": {
|
||||
"item": {
|
||||
"direct": {
|
||||
|
||||
@@ -649,13 +649,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"worktree": {
|
||||
"background": {
|
||||
"terminals": {
|
||||
"setupTitle": "设置"
|
||||
}
|
||||
}
|
||||
},
|
||||
"work": {
|
||||
"item": {
|
||||
"direct": {
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import {
|
||||
registerEagerPtyBuffer,
|
||||
type EagerPtyHandle
|
||||
} from '@/components/terminal-pane/pty-dispatcher'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
function persistExitedPaneOutput(tabId: string, leafId: string, output: string): void {
|
||||
const store = useAppStore.getState()
|
||||
const layout = store.terminalLayoutsByTabId[tabId]
|
||||
if (!layout) {
|
||||
return
|
||||
}
|
||||
const { ptyIdsByLeafId: existingPtyIds, buffersByLeafId: existingBuffers, ...rest } = layout
|
||||
const nextPtyIds = { ...existingPtyIds }
|
||||
delete nextPtyIds[leafId]
|
||||
const trimmedOutput = output.trim() ? output : ''
|
||||
store.setTabLayout(tabId, {
|
||||
...rest,
|
||||
...(Object.keys(nextPtyIds).length > 0 ? { ptyIdsByLeafId: nextPtyIds } : {}),
|
||||
...(trimmedOutput
|
||||
? {
|
||||
buffersByLeafId: {
|
||||
...existingBuffers,
|
||||
[leafId]: output
|
||||
}
|
||||
}
|
||||
: existingBuffers
|
||||
? { buffersByLeafId: existingBuffers }
|
||||
: {})
|
||||
})
|
||||
}
|
||||
|
||||
// Why the incarnation: a relay-recycled id can hold the previous owner's exit, and draining that
|
||||
// into this handler tears the pane down seconds after it launched.
|
||||
export function registerBackgroundPaneBuffer(
|
||||
tabId: string,
|
||||
leafId: string,
|
||||
pane: { ptyId: string; incarnationId?: string }
|
||||
): void {
|
||||
let eagerBuffer: EagerPtyHandle | null = null
|
||||
const onExit = (exitPtyId: string): void => {
|
||||
persistExitedPaneOutput(tabId, leafId, eagerBuffer?.flush() ?? '')
|
||||
useAppStore.getState().clearTabPtyId(tabId, exitPtyId)
|
||||
}
|
||||
eagerBuffer = registerEagerPtyBuffer(pane.ptyId, onExit, pane.incarnationId)
|
||||
}
|
||||
@@ -1,418 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockSpawn = vi.fn()
|
||||
const mockKill = vi.fn()
|
||||
const mockCreateTab = vi.fn()
|
||||
const mockSetTabCustomTitle = vi.fn()
|
||||
const mockSetTabColor = vi.fn()
|
||||
const mockSetTabLayout = vi.fn()
|
||||
const mockUpdateTabPtyId = vi.fn()
|
||||
const mockClearTabPtyId = vi.fn()
|
||||
const mockCloseTab = vi.fn()
|
||||
const mockRegisterEagerPtyBuffer = vi.fn()
|
||||
const mockOwnerEnvironmentId = vi.fn()
|
||||
|
||||
let uuidIndex = 0
|
||||
|
||||
const setupLaunch = {
|
||||
runnerScriptPath: '/tmp/setup.sh',
|
||||
envVars: { ORCA_WORKTREE_PATH: '/repo/worktree' }
|
||||
}
|
||||
|
||||
const state = {
|
||||
settings: { activeRuntimeEnvironmentId: null as string | null, setupScriptLaunchMode: 'new-tab' },
|
||||
repos: [{ id: 'repo-1', connectionId: null as string | null }],
|
||||
worktreesByRepo: {
|
||||
'repo-1': [
|
||||
{
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
path: '/repo/worktree',
|
||||
displayName: 'Worktree'
|
||||
}
|
||||
]
|
||||
},
|
||||
tabsByWorktree: { 'wt-1': [] as { id: string }[] },
|
||||
allWorktrees: vi.fn(() => state.worktreesByRepo['repo-1'] ?? []),
|
||||
createTab: mockCreateTab,
|
||||
setTabCustomTitle: mockSetTabCustomTitle,
|
||||
setTabColor: mockSetTabColor,
|
||||
setTabLayout: mockSetTabLayout,
|
||||
updateTabPtyId: mockUpdateTabPtyId,
|
||||
clearTabPtyId: mockClearTabPtyId,
|
||||
closeTab: mockCloseTab
|
||||
}
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => state
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/browser-uuid', () => ({
|
||||
createBrowserUuid: () => `00000000-0000-4000-8000-${String(++uuidIndex).padStart(12, '0')}`
|
||||
}))
|
||||
|
||||
vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({
|
||||
registerEagerPtyBuffer: mockRegisterEagerPtyBuffer
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/worktree-runtime-owner', () => ({
|
||||
getRuntimeEnvironmentIdForWorktree: () => mockOwnerEnvironmentId()
|
||||
}))
|
||||
|
||||
describe('launchWorktreeBackgroundTerminals', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
uuidIndex = 0
|
||||
state.settings = { activeRuntimeEnvironmentId: null, setupScriptLaunchMode: 'new-tab' }
|
||||
state.repos = [{ id: 'repo-1', connectionId: null }]
|
||||
state.worktreesByRepo['repo-1'] = [
|
||||
{
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
path: '/repo/worktree',
|
||||
displayName: 'Worktree'
|
||||
}
|
||||
]
|
||||
state.tabsByWorktree = { 'wt-1': [] }
|
||||
let tabIndex = 0
|
||||
mockCreateTab.mockImplementation(() => {
|
||||
const tab = { id: `tab-${++tabIndex}` }
|
||||
state.tabsByWorktree['wt-1'].push(tab)
|
||||
return tab
|
||||
})
|
||||
// Placement reads the row back from the store, so title and color writes must land there.
|
||||
const patchTab = (tabId: string, patch: { customTitle?: string; color?: string }): void => {
|
||||
state.tabsByWorktree['wt-1'] = state.tabsByWorktree['wt-1'].map((tab) =>
|
||||
tab.id === tabId ? { ...tab, ...patch } : tab
|
||||
)
|
||||
}
|
||||
mockSetTabCustomTitle.mockImplementation((tabId: string, customTitle: string) =>
|
||||
patchTab(tabId, { customTitle })
|
||||
)
|
||||
mockSetTabColor.mockImplementation((tabId: string, color: string) => patchTab(tabId, { color }))
|
||||
mockCloseTab.mockImplementation((tabId: string) => {
|
||||
state.tabsByWorktree['wt-1'] = state.tabsByWorktree['wt-1'].filter((tab) => tab.id !== tabId)
|
||||
})
|
||||
let ptyIndex = 0
|
||||
mockSpawn.mockImplementation(async () => ({ id: `pty-${++ptyIndex}` }))
|
||||
mockOwnerEnvironmentId.mockReturnValue(null)
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
pty: {
|
||||
spawn: mockSpawn,
|
||||
kill: mockKill
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('spawns default tabs and setup as inactive background terminals', async () => {
|
||||
const { launchWorktreeBackgroundTerminals } =
|
||||
await import('./launch-worktree-background-terminals')
|
||||
|
||||
await launchWorktreeBackgroundTerminals({
|
||||
worktreeId: 'wt-1',
|
||||
setup: setupLaunch,
|
||||
defaultTabs: {
|
||||
runCommands: true,
|
||||
tabs: [{ title: 'Dev', color: '#f97316', command: 'pnpm dev' }]
|
||||
}
|
||||
})
|
||||
|
||||
expect(mockCreateTab).toHaveBeenCalledTimes(2)
|
||||
expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, {
|
||||
activate: false,
|
||||
recordInteraction: false
|
||||
})
|
||||
expect(mockSetTabCustomTitle).toHaveBeenCalledWith('tab-1', 'Dev', {
|
||||
recordInteraction: false
|
||||
})
|
||||
expect(mockSetTabColor).toHaveBeenCalledWith('tab-1', '#f97316')
|
||||
expect(mockSetTabCustomTitle).toHaveBeenCalledWith('tab-2', 'Setup', {
|
||||
recordInteraction: false
|
||||
})
|
||||
expect(mockSpawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
cwd: '/repo/worktree',
|
||||
command: 'pnpm dev',
|
||||
connectionId: null,
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-1',
|
||||
leafId: '00000000-0000-4000-8000-000000000001',
|
||||
placement: {
|
||||
kind: 'new-tab',
|
||||
row: expect.objectContaining({ customTitle: 'Dev', color: '#f97316' })
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(mockSpawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
command: 'bash /tmp/setup.sh',
|
||||
env: expect.objectContaining({ ORCA_WORKTREE_PATH: '/repo/worktree' }),
|
||||
tabId: 'tab-2',
|
||||
leafId: '00000000-0000-4000-8000-000000000002',
|
||||
placement: { kind: 'new-tab', row: expect.objectContaining({ customTitle: 'Setup' }) }
|
||||
})
|
||||
)
|
||||
expect(mockUpdateTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-1')
|
||||
expect(mockUpdateTabPtyId).toHaveBeenCalledWith('tab-2', 'pty-2')
|
||||
expect(mockRegisterEagerPtyBuffer).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('spawns setup in a split when setup launch mode requests a split', async () => {
|
||||
state.settings = { activeRuntimeEnvironmentId: null, setupScriptLaunchMode: 'split-horizontal' }
|
||||
const { launchWorktreeBackgroundTerminals } =
|
||||
await import('./launch-worktree-background-terminals')
|
||||
|
||||
await launchWorktreeBackgroundTerminals({
|
||||
worktreeId: 'wt-1',
|
||||
setup: setupLaunch
|
||||
})
|
||||
|
||||
expect(mockCreateTab).toHaveBeenCalledTimes(1)
|
||||
expect(mockSpawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
tabId: 'tab-1',
|
||||
leafId: '00000000-0000-4000-8000-000000000001',
|
||||
placement: { kind: 'new-tab', row: expect.any(Object) }
|
||||
})
|
||||
)
|
||||
expect(mockSpawn.mock.calls[0]?.[0]).not.toHaveProperty('command')
|
||||
expect(mockSpawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
command: 'bash /tmp/setup.sh',
|
||||
tabId: 'tab-1',
|
||||
leafId: '00000000-0000-4000-8000-000000000002',
|
||||
placement: {
|
||||
kind: 'split',
|
||||
parentLeafId: '00000000-0000-4000-8000-000000000001',
|
||||
direction: 'horizontal',
|
||||
proposedRoot: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', leafId: '00000000-0000-4000-8000-000000000001' },
|
||||
second: { type: 'leaf', leafId: '00000000-0000-4000-8000-000000000002' }
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(mockSetTabLayout).toHaveBeenLastCalledWith(
|
||||
'tab-1',
|
||||
expect.objectContaining({
|
||||
root: expect.objectContaining({
|
||||
type: 'split',
|
||||
direction: 'horizontal'
|
||||
}),
|
||||
ptyIdsByLeafId: {
|
||||
'00000000-0000-4000-8000-000000000001': 'pty-1',
|
||||
'00000000-0000-4000-8000-000000000002': 'pty-2'
|
||||
},
|
||||
titlesByLeafId: { '00000000-0000-4000-8000-000000000002': 'Setup' }
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('spawns an initial terminal before a setup-only new-tab launch', async () => {
|
||||
const { launchWorktreeBackgroundTerminals } =
|
||||
await import('./launch-worktree-background-terminals')
|
||||
|
||||
await launchWorktreeBackgroundTerminals({
|
||||
worktreeId: 'wt-1',
|
||||
setup: setupLaunch
|
||||
})
|
||||
|
||||
expect(mockCreateTab).toHaveBeenCalledTimes(2)
|
||||
expect(mockSpawn.mock.calls[0]?.[0]).not.toHaveProperty('command')
|
||||
expect(mockSpawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
command: 'bash /tmp/setup.sh',
|
||||
tabId: 'tab-2',
|
||||
leafId: '00000000-0000-4000-8000-000000000002'
|
||||
})
|
||||
)
|
||||
expect(mockSetTabCustomTitle).toHaveBeenCalledWith('tab-2', 'Setup', {
|
||||
recordInteraction: false
|
||||
})
|
||||
})
|
||||
|
||||
it('uses Windows setup commands for Windows SSH runner paths', async () => {
|
||||
state.repos = [{ id: 'repo-1', connectionId: 'ssh-windows' }]
|
||||
state.worktreesByRepo['repo-1'] = [
|
||||
{
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
path: 'C:\\repo\\worktree',
|
||||
displayName: 'Worktree'
|
||||
}
|
||||
]
|
||||
const { launchWorktreeBackgroundTerminals } =
|
||||
await import('./launch-worktree-background-terminals')
|
||||
|
||||
await launchWorktreeBackgroundTerminals({
|
||||
worktreeId: 'wt-1',
|
||||
setup: {
|
||||
runnerScriptPath: 'C:\\repo\\.git\\worktrees\\wt\\orca\\setup-runner.cmd',
|
||||
envVars: { ORCA_WORKTREE_PATH: 'C:\\repo\\worktree' }
|
||||
}
|
||||
})
|
||||
|
||||
expect(mockSpawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
command: 'cmd.exe /c "C:\\repo\\.git\\worktrees\\wt\\orca\\setup-runner.cmd"',
|
||||
connectionId: 'ssh-windows'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('uses configured WSL setup commands for Windows bash runner paths', async () => {
|
||||
state.repos = [{ id: 'repo-1', connectionId: null }]
|
||||
state.worktreesByRepo['repo-1'] = [
|
||||
{
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
path: 'C:\\repo\\worktree',
|
||||
displayName: 'Worktree'
|
||||
}
|
||||
]
|
||||
const { launchWorktreeBackgroundTerminals } =
|
||||
await import('./launch-worktree-background-terminals')
|
||||
|
||||
await launchWorktreeBackgroundTerminals({
|
||||
worktreeId: 'wt-1',
|
||||
setup: {
|
||||
runnerScriptPath: 'C:\\repo\\.git\\worktrees\\wt\\orca\\setup-runner.sh',
|
||||
shell: { family: 'posix', executable: 'wsl.exe' },
|
||||
envVars: { ORCA_WORKTREE_PATH: 'C:\\repo\\worktree' }
|
||||
}
|
||||
})
|
||||
|
||||
expect(mockSpawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
command: 'bash /mnt/c/repo/.git/worktrees/wt/orca/setup-runner.sh',
|
||||
connectionId: null
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('still attempts setup when a default tab fails to spawn', async () => {
|
||||
const spawnError = new Error('pty unavailable')
|
||||
mockSpawn
|
||||
.mockRejectedValueOnce(spawnError)
|
||||
.mockResolvedValueOnce({ id: 'pty-initial' })
|
||||
.mockResolvedValueOnce({ id: 'pty-setup' })
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const { launchWorktreeBackgroundTerminals } =
|
||||
await import('./launch-worktree-background-terminals')
|
||||
|
||||
await launchWorktreeBackgroundTerminals({
|
||||
worktreeId: 'wt-1',
|
||||
setup: setupLaunch,
|
||||
defaultTabs: {
|
||||
runCommands: true,
|
||||
tabs: [{ title: 'Dev', command: 'pnpm dev' }]
|
||||
}
|
||||
})
|
||||
|
||||
expect(mockCloseTab).toHaveBeenCalledWith('tab-1', {
|
||||
recordInteraction: false,
|
||||
reason: 'cleanup'
|
||||
})
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(3)
|
||||
expect(mockSpawn).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
command: 'bash /tmp/setup.sh',
|
||||
tabId: 'tab-3'
|
||||
})
|
||||
)
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[automations] Failed to launch workspace default tab:',
|
||||
spawnError
|
||||
)
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('does not duplicate runtime-owned setup/defaultTabs handled by runtime create', async () => {
|
||||
mockOwnerEnvironmentId.mockReturnValue('env-1')
|
||||
const { launchWorktreeBackgroundTerminals } =
|
||||
await import('./launch-worktree-background-terminals')
|
||||
|
||||
await launchWorktreeBackgroundTerminals({
|
||||
worktreeId: 'wt-1',
|
||||
setup: setupLaunch,
|
||||
defaultTabs: { runCommands: true, tabs: [{ title: 'Dev', command: 'pnpm dev' }] }
|
||||
})
|
||||
|
||||
expect(mockCreateTab).not.toHaveBeenCalled()
|
||||
expect(mockSpawn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('kills a PTY whose tab is closed before the spawn resolves', async () => {
|
||||
let resolveSpawn!: (result: { id: string }) => void
|
||||
mockSpawn.mockReturnValueOnce(
|
||||
new Promise<{ id: string }>((resolve) => {
|
||||
resolveSpawn = resolve
|
||||
})
|
||||
)
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const { launchWorktreeBackgroundTerminals } =
|
||||
await import('./launch-worktree-background-terminals')
|
||||
|
||||
const launch = launchWorktreeBackgroundTerminals({
|
||||
worktreeId: 'wt-1',
|
||||
defaultTabs: { runCommands: true, tabs: [{ command: 'pnpm dev' }] }
|
||||
})
|
||||
await vi.waitFor(() => expect(mockCreateTab).toHaveBeenCalledOnce())
|
||||
state.tabsByWorktree['wt-1'] = []
|
||||
resolveSpawn({ id: 'pty-after-close' })
|
||||
await launch
|
||||
|
||||
expect(mockKill).toHaveBeenCalledWith('pty-after-close')
|
||||
expect(mockUpdateTabPtyId).not.toHaveBeenCalled()
|
||||
expect(mockRegisterEagerPtyBuffer).not.toHaveBeenCalled()
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('kills a late setup split PTY when its parent tab closes', async () => {
|
||||
state.settings = { activeRuntimeEnvironmentId: null, setupScriptLaunchMode: 'split-horizontal' }
|
||||
let resolveSetupSpawn!: (result: { id: string }) => void
|
||||
mockSpawn.mockResolvedValueOnce({ id: 'pty-primary' }).mockReturnValueOnce(
|
||||
new Promise<{ id: string }>((resolve) => {
|
||||
resolveSetupSpawn = resolve
|
||||
})
|
||||
)
|
||||
const { launchWorktreeBackgroundTerminals } =
|
||||
await import('./launch-worktree-background-terminals')
|
||||
|
||||
const launch = launchWorktreeBackgroundTerminals({
|
||||
worktreeId: 'wt-1',
|
||||
setup: setupLaunch
|
||||
})
|
||||
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(2))
|
||||
state.closeTab('tab-1')
|
||||
resolveSetupSpawn({ id: 'pty-setup-after-close' })
|
||||
await launch
|
||||
|
||||
expect(mockKill).toHaveBeenCalledWith('pty-setup-after-close')
|
||||
expect(mockUpdateTabPtyId).toHaveBeenCalledTimes(1)
|
||||
expect(mockUpdateTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-primary')
|
||||
expect(mockSetTabLayout).not.toHaveBeenCalledWith(
|
||||
'tab-1',
|
||||
expect.objectContaining({
|
||||
ptyIdsByLeafId: expect.objectContaining({
|
||||
'00000000-0000-4000-8000-000000000002': 'pty-setup-after-close'
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,310 +0,0 @@
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target'
|
||||
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers'
|
||||
import { retireUnownedTerminal } from '@/lib/retire-unowned-background-terminal'
|
||||
import { registerBackgroundPaneBuffer } from '@/lib/background-pane-exit-output'
|
||||
import { terminalPanePlacementRow } from '@/lib/terminal-pane-placement-row'
|
||||
import { useAppStore } from '@/store'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { makePaneKey } from '../../../shared/stable-pane-id'
|
||||
import {
|
||||
buildSetupRunnerCommand,
|
||||
getSetupRunnerCommandPlatformForPath
|
||||
} from '../../../shared/setup-runner-command'
|
||||
import type {
|
||||
TerminalLayoutSnapshot,
|
||||
TerminalPaneLayoutNode
|
||||
} from '../../../shared/terminal-tab-types'
|
||||
import type { TerminalPanePlacement } from '../../../shared/terminal-pane-placement'
|
||||
import type {
|
||||
WorktreeDefaultTabsLaunch,
|
||||
WorktreeSetupLaunch
|
||||
} from '../../../shared/worktree/launch-types'
|
||||
import type { Worktree } from '../../../shared/worktree/types'
|
||||
|
||||
type BackgroundPane = {
|
||||
leafId: string
|
||||
ptyId: string
|
||||
}
|
||||
|
||||
type BackgroundTab = {
|
||||
tabId: string
|
||||
primary: BackgroundPane
|
||||
}
|
||||
|
||||
type BackgroundTerminalLaunch = {
|
||||
command?: string
|
||||
env?: Record<string, string>
|
||||
title?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
function getSetupTabTitle(): string {
|
||||
return translate('auto.lib.launch.worktree.background.terminals.setupTitle', 'Setup')
|
||||
}
|
||||
|
||||
export type LaunchWorktreeBackgroundTerminalsArgs = {
|
||||
worktreeId: string
|
||||
setup?: WorktreeSetupLaunch
|
||||
defaultTabs?: WorktreeDefaultTabsLaunch
|
||||
}
|
||||
|
||||
function buildPaneEnv(
|
||||
worktreeId: string,
|
||||
tabId: string,
|
||||
leafId: string,
|
||||
env: Record<string, string> | undefined
|
||||
): Record<string, string> {
|
||||
return {
|
||||
...env,
|
||||
ORCA_PANE_KEY: makePaneKey(tabId, leafId),
|
||||
ORCA_TAB_ID: tabId,
|
||||
ORCA_WORKTREE_ID: worktreeId
|
||||
}
|
||||
}
|
||||
|
||||
function buildSplitRoot(
|
||||
firstLeafId: string,
|
||||
secondLeafId: string,
|
||||
direction: 'horizontal' | 'vertical'
|
||||
): TerminalPaneLayoutNode {
|
||||
return {
|
||||
type: 'split',
|
||||
direction,
|
||||
first: { type: 'leaf', leafId: firstLeafId },
|
||||
second: { type: 'leaf', leafId: secondLeafId }
|
||||
}
|
||||
}
|
||||
|
||||
function buildSplitLayout(
|
||||
first: BackgroundPane,
|
||||
second: BackgroundPane,
|
||||
direction: 'horizontal' | 'vertical',
|
||||
secondTitle: string
|
||||
): TerminalLayoutSnapshot {
|
||||
return {
|
||||
root: buildSplitRoot(first.leafId, second.leafId, direction),
|
||||
activeLeafId: first.leafId,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: {
|
||||
[first.leafId]: first.ptyId,
|
||||
[second.leafId]: second.ptyId
|
||||
},
|
||||
titlesByLeafId: {
|
||||
[second.leafId]: secondTitle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildSetupCommand(setup: WorktreeSetupLaunch): string {
|
||||
// Why: background setup tabs can launch later, so they must reuse the same shell chosen when the runner was written.
|
||||
return buildSetupRunnerCommand(
|
||||
setup.runnerScriptPath,
|
||||
getSetupRunnerCommandPlatformForPath(setup.runnerScriptPath, 'posix'),
|
||||
setup.shell
|
||||
)
|
||||
}
|
||||
|
||||
/** The id a background pane got, plus which lifetime of it this spawn owns. */
|
||||
type SpawnedPane = { ptyId: string; incarnationId?: string }
|
||||
|
||||
async function spawnPane(args: {
|
||||
worktree: Worktree
|
||||
connectionId: string | null
|
||||
tabId: string
|
||||
leafId: string
|
||||
placement: TerminalPanePlacement
|
||||
command?: string
|
||||
env?: Record<string, string>
|
||||
}): Promise<SpawnedPane> {
|
||||
const result = await window.api.pty.spawn({
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: args.worktree.path,
|
||||
...(args.command ? { command: args.command } : {}),
|
||||
env: buildPaneEnv(args.worktree.id, args.tabId, args.leafId, args.env),
|
||||
connectionId: args.connectionId,
|
||||
worktreeId: args.worktree.id,
|
||||
tabId: args.tabId,
|
||||
leafId: args.leafId,
|
||||
placement: args.placement
|
||||
})
|
||||
return {
|
||||
ptyId: result.id,
|
||||
...(result.incarnationId ? { incarnationId: result.incarnationId } : {})
|
||||
}
|
||||
}
|
||||
|
||||
async function createBackgroundTab(args: {
|
||||
worktree: Worktree
|
||||
connectionId: string | null
|
||||
launch: BackgroundTerminalLaunch
|
||||
}): Promise<BackgroundTab> {
|
||||
const store = useAppStore.getState()
|
||||
const tab = store.createTab(args.worktree.id, undefined, undefined, {
|
||||
activate: false,
|
||||
recordInteraction: false
|
||||
})
|
||||
if (args.launch.title) {
|
||||
store.setTabCustomTitle(tab.id, args.launch.title, { recordInteraction: false })
|
||||
}
|
||||
if (args.launch.color) {
|
||||
store.setTabColor(tab.id, args.launch.color)
|
||||
}
|
||||
|
||||
const leafId = createBrowserUuid()
|
||||
store.setTabLayout(tab.id, singlePaneLayoutSnapshot(leafId))
|
||||
const created =
|
||||
useAppStore.getState().tabsByWorktree[args.worktree.id]?.find(({ id }) => id === tab.id) ?? tab
|
||||
let pane: SpawnedPane
|
||||
try {
|
||||
pane = await spawnPane({
|
||||
worktree: args.worktree,
|
||||
connectionId: args.connectionId,
|
||||
tabId: tab.id,
|
||||
leafId,
|
||||
placement: { kind: 'new-tab', row: terminalPanePlacementRow(created) },
|
||||
command: args.launch.command,
|
||||
env: args.launch.env
|
||||
})
|
||||
} catch (error) {
|
||||
store.closeTab(tab.id, { recordInteraction: false, reason: 'cleanup' })
|
||||
throw error
|
||||
}
|
||||
if (
|
||||
await retireUnownedTerminal({
|
||||
owner: { tabId: tab.id },
|
||||
ptyId: pane.ptyId,
|
||||
runtimeTarget: { kind: 'local' }
|
||||
})
|
||||
) {
|
||||
throw new Error('The terminal tab was closed before its session finished starting.')
|
||||
}
|
||||
store.updateTabPtyId(tab.id, pane.ptyId)
|
||||
store.setTabLayout(tab.id, singlePaneLayoutSnapshot(leafId, pane.ptyId))
|
||||
registerBackgroundPaneBuffer(tab.id, leafId, pane)
|
||||
return { tabId: tab.id, primary: { leafId, ptyId: pane.ptyId } }
|
||||
}
|
||||
|
||||
async function addSetupSplit(args: {
|
||||
worktree: Worktree
|
||||
connectionId: string | null
|
||||
tab: BackgroundTab
|
||||
setup: WorktreeSetupLaunch
|
||||
direction: 'horizontal' | 'vertical'
|
||||
}): Promise<void> {
|
||||
const store = useAppStore.getState()
|
||||
const setupLeafId = createBrowserUuid()
|
||||
const setupPane = await spawnPane({
|
||||
worktree: args.worktree,
|
||||
connectionId: args.connectionId,
|
||||
tabId: args.tab.tabId,
|
||||
leafId: setupLeafId,
|
||||
placement: {
|
||||
kind: 'split',
|
||||
parentLeafId: args.tab.primary.leafId,
|
||||
direction: args.direction,
|
||||
proposedRoot: buildSplitRoot(args.tab.primary.leafId, setupLeafId, args.direction)
|
||||
},
|
||||
command: buildSetupCommand(args.setup),
|
||||
env: args.setup.envVars
|
||||
})
|
||||
if (
|
||||
await retireUnownedTerminal({
|
||||
owner: { tabId: args.tab.tabId },
|
||||
ptyId: setupPane.ptyId,
|
||||
runtimeTarget: { kind: 'local' }
|
||||
})
|
||||
) {
|
||||
return
|
||||
}
|
||||
store.updateTabPtyId(args.tab.tabId, setupPane.ptyId)
|
||||
store.setTabLayout(
|
||||
args.tab.tabId,
|
||||
buildSplitLayout(
|
||||
args.tab.primary,
|
||||
{ leafId: setupLeafId, ptyId: setupPane.ptyId },
|
||||
args.direction,
|
||||
getSetupTabTitle()
|
||||
)
|
||||
)
|
||||
registerBackgroundPaneBuffer(args.tab.tabId, setupLeafId, setupPane)
|
||||
}
|
||||
|
||||
function getDefaultTabLaunches(
|
||||
defaultTabs: WorktreeDefaultTabsLaunch | undefined
|
||||
): BackgroundTerminalLaunch[] {
|
||||
return (defaultTabs?.tabs ?? []).map((tab) => {
|
||||
const command = tab.command?.trim()
|
||||
return {
|
||||
...(tab.title ? { title: tab.title } : {}),
|
||||
...(tab.color ? { color: tab.color } : {}),
|
||||
...(command && defaultTabs?.runCommands ? { command } : {})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function launchWorktreeBackgroundTerminals(
|
||||
args: LaunchWorktreeBackgroundTerminalsArgs
|
||||
): Promise<void> {
|
||||
if (!args.setup && !args.defaultTabs) {
|
||||
return
|
||||
}
|
||||
const store = useAppStore.getState()
|
||||
const runtimeTarget = runtimeTargetForOwnerEnvironment(
|
||||
getRuntimeEnvironmentIdForWorktree(store, args.worktreeId)
|
||||
)
|
||||
if (runtimeTarget.kind === 'environment') {
|
||||
// Runtime-owned worktrees materialize setup/defaultTabs inside createManagedWorktree.
|
||||
return
|
||||
}
|
||||
|
||||
const worktree = store.allWorktrees().find((entry) => entry.id === args.worktreeId)
|
||||
if (!worktree) {
|
||||
throw new Error('The target workspace is no longer available.')
|
||||
}
|
||||
const repo = store.repos.find((entry) => entry.id === worktree.repoId)
|
||||
const connectionId = repo?.connectionId ?? null
|
||||
const defaultLaunches = getDefaultTabLaunches(args.defaultTabs)
|
||||
const launchedTabs: BackgroundTab[] = []
|
||||
|
||||
for (const launch of defaultLaunches) {
|
||||
try {
|
||||
launchedTabs.push(await createBackgroundTab({ worktree, connectionId, launch }))
|
||||
} catch (error) {
|
||||
console.warn('[automations] Failed to launch workspace default tab:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const setupMode = store.settings?.setupScriptLaunchMode ?? 'new-tab'
|
||||
const shouldSplitSetup =
|
||||
args.setup && (setupMode === 'split-horizontal' || setupMode === 'split-vertical')
|
||||
if (shouldSplitSetup) {
|
||||
const primaryTab =
|
||||
launchedTabs[0] ?? (await createBackgroundTab({ worktree, connectionId, launch: {} }))
|
||||
await addSetupSplit({
|
||||
worktree,
|
||||
connectionId,
|
||||
tab: primaryTab,
|
||||
setup: args.setup!,
|
||||
direction: setupMode === 'split-horizontal' ? 'horizontal' : 'vertical'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (args.setup) {
|
||||
if (launchedTabs.length === 0) {
|
||||
launchedTabs.push(await createBackgroundTab({ worktree, connectionId, launch: {} }))
|
||||
}
|
||||
await createBackgroundTab({
|
||||
worktree,
|
||||
connectionId,
|
||||
launch: {
|
||||
title: getSetupTabTitle(),
|
||||
command: buildSetupCommand(args.setup),
|
||||
env: args.setup.envVars
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user