mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 00:03:52 +00:00
fix(vm): preserve host through workspace completion
This commit is contained in:
@@ -773,6 +773,9 @@
|
||||
},
|
||||
"ephemeralVmWorktreeCreation": {
|
||||
"sparseCheckoutUnsupported": "Provisioned-root recipes do not support sparse checkout."
|
||||
},
|
||||
"worktreeCreationFlow": {
|
||||
"backgroundStartupOwnerAmbiguous": "Workspace created, but automatic startup was skipped because its host is ambiguous."
|
||||
}
|
||||
},
|
||||
"hooks": {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
|
||||
import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation'
|
||||
|
||||
export async function preflightWorktreeCreationAgentTrust(
|
||||
request: WorktreeCreationRequest,
|
||||
path: string,
|
||||
connectionId?: string | null
|
||||
): Promise<void> {
|
||||
if (!request.agent || !window.api.agentTrust?.markTrusted) {
|
||||
return
|
||||
}
|
||||
const preflight = TUI_AGENT_CONFIG[request.agent].preflightTrust
|
||||
if (!preflight) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await window.api.agentTrust.markTrusted({
|
||||
preset: preflight,
|
||||
workspacePath: path,
|
||||
...(connectionId ? { connectionId } : {})
|
||||
})
|
||||
} catch {
|
||||
// Best-effort: the created workspace must remain usable if trust persistence fails.
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const FLOW_SOURCE = readFileSync(join(__dirname, 'worktree-creation-flow.ts'), 'utf8')
|
||||
const TRUST_SOURCE = readFileSync(join(__dirname, 'worktree-creation-agent-trust.ts'), 'utf8')
|
||||
|
||||
function sourceBetween(source: string, startPattern: string, endPattern: string): string {
|
||||
const start = source.indexOf(startPattern)
|
||||
@@ -14,23 +15,19 @@ function sourceBetween(source: string, startPattern: string, endPattern: string)
|
||||
|
||||
describe('worktree creation flow agent trust preflight', () => {
|
||||
it('forwards the repo SSH connection id when pre-marking agent trust', () => {
|
||||
const preflight = sourceBetween(
|
||||
FLOW_SOURCE,
|
||||
'async function preflightAgentTrust',
|
||||
'async function executeWorktreeCreation'
|
||||
)
|
||||
const createFlow = sourceBetween(
|
||||
FLOW_SOURCE,
|
||||
'const backendSpawned = result.startupTerminal?.spawned === true',
|
||||
'// `createWorktree` already inserted the real worktree row'
|
||||
)
|
||||
|
||||
expect(preflight).toContain('connectionId?: string | null')
|
||||
expect(preflight).toContain('...(connectionId ? { connectionId } : {})')
|
||||
expect(TRUST_SOURCE).toContain('connectionId?: string | null')
|
||||
expect(TRUST_SOURCE).toContain('...(connectionId ? { connectionId } : {})')
|
||||
expect(createFlow).toContain('repoConnectionId')
|
||||
expect(createFlow).toContain('repo.id === worktree.repoId')
|
||||
expect(createFlow).toContain('findRepoForHost(trustState.repos, worktree.repoId, {')
|
||||
expect(createFlow).toContain('hostId: worktree.hostId')
|
||||
expect(createFlow).toContain(
|
||||
'await preflightAgentTrust(preparedRequest, worktree.path, repoConnectionId)'
|
||||
'await preflightWorktreeCreationAgentTrust(preparedRequest, worktree.path, repoConnectionId)'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import type { PendingWorktreeCreation, WorktreeCreationRequest } from './pending-worktree-creation'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const store = {
|
||||
activePendingCreationId: null as string | null,
|
||||
activeView: 'terminal',
|
||||
pendingWorktreeCreations: {} as Record<string, PendingWorktreeCreation>,
|
||||
repos: [],
|
||||
settings: {},
|
||||
beginPendingWorktreeCreation: vi.fn((entry: PendingWorktreeCreation) => {
|
||||
store.pendingWorktreeCreations[entry.creationId] = entry
|
||||
store.activePendingCreationId = entry.creationId
|
||||
}),
|
||||
updatePendingWorktreeCreation: vi.fn(),
|
||||
removePendingWorktreeCreation: vi.fn((creationId: string) => {
|
||||
delete store.pendingWorktreeCreations[creationId]
|
||||
}),
|
||||
createWorktree: vi.fn(),
|
||||
updateWorktreeMeta: vi.fn(),
|
||||
setActivePendingWorktreeCreation: vi.fn(),
|
||||
setActiveView: vi.fn(),
|
||||
setSidebarOpen: vi.fn()
|
||||
}
|
||||
return {
|
||||
store,
|
||||
activateAndRevealWorktree: vi.fn(() => ({ primaryTabId: null })),
|
||||
ensureWorktreeHasInitialTerminal: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
seedAgentTabState: vi.fn(),
|
||||
ensureAgentStartup: vi.fn(),
|
||||
resolveExplicitRoute: vi.fn<
|
||||
() =>
|
||||
| {
|
||||
kind: 'resolved'
|
||||
route: { executionHostId: 'ssh:requested'; runtimeEnvironmentId: null }
|
||||
}
|
||||
| { kind: 'ambiguous' }
|
||||
>(() => ({
|
||||
kind: 'resolved',
|
||||
route: { executionHostId: 'ssh:requested', runtimeEnvironmentId: null }
|
||||
})),
|
||||
prepareRequestForCreate: vi.fn(
|
||||
async (_creationId: string, request: WorktreeCreationRequest) => request
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.store } }))
|
||||
vi.mock('@/lib/browser-uuid', () => ({ createBrowserUuid: () => 'creation-host-routing' }))
|
||||
vi.mock('@/lib/worktree-activation', () => ({
|
||||
activateAndRevealWorktree: mocks.activateAndRevealWorktree,
|
||||
ensureWorktreeHasInitialTerminal: mocks.ensureWorktreeHasInitialTerminal
|
||||
}))
|
||||
vi.mock('@/lib/ephemeral-vm-worktree-creation', () => ({
|
||||
prepareRequestForCreate: mocks.prepareRequestForCreate,
|
||||
adoptEphemeralVmProvisionedRoot: vi.fn(),
|
||||
attachEphemeralVmRuntimeToWorkspace: vi.fn(),
|
||||
cleanupEphemeralVmRuntimeForFailedCreate: vi.fn()
|
||||
}))
|
||||
vi.mock('@/lib/worktree-creation-agent-seeds', () => ({
|
||||
seedAgentTabStateAfterWorktreeCreate: mocks.seedAgentTabState
|
||||
}))
|
||||
vi.mock('@/lib/worktree-operation-route', () => ({
|
||||
resolveExplicitWorktreeOperationRouteResult: mocks.resolveExplicitRoute
|
||||
}))
|
||||
vi.mock('@/lib/new-workspace', () => ({ ensureAgentStartupInTerminal: mocks.ensureAgentStartup }))
|
||||
vi.mock('@/lib/workspace-activation-terminal-focus', () => ({
|
||||
queueWorkspaceActivationTerminalFocus: vi.fn()
|
||||
}))
|
||||
vi.mock('sonner', () => ({ toast: { error: mocks.toastError } }))
|
||||
|
||||
import { runBackgroundWorktreeCreation } from './worktree-creation-flow'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.store.activePendingCreationId = null
|
||||
mocks.store.activeView = 'terminal'
|
||||
mocks.store.pendingWorktreeCreations = {}
|
||||
mocks.resolveExplicitRoute.mockReturnValue({
|
||||
kind: 'resolved',
|
||||
route: { executionHostId: 'ssh:requested', runtimeEnvironmentId: null }
|
||||
})
|
||||
mocks.store.createWorktree.mockResolvedValue({
|
||||
worktree: { id: 'wt-shared', repoId: 'repo-1', hostId: 'ssh:requested' }
|
||||
})
|
||||
})
|
||||
|
||||
it('fails background startup closed when duplicate hosts make the bare id ambiguous', async () => {
|
||||
mocks.store.activeView = 'tasks'
|
||||
mocks.resolveExplicitRoute.mockReturnValue({ kind: 'ambiguous' })
|
||||
|
||||
runBackgroundWorktreeCreation({
|
||||
repoId: 'repo-1',
|
||||
name: 'remote-workspace',
|
||||
setupDecision: 'inherit',
|
||||
agent: 'codex',
|
||||
pendingFirstAgentMessageRename: false,
|
||||
note: '',
|
||||
startupPlan: {
|
||||
agent: 'codex',
|
||||
launchCommand: 'codex',
|
||||
expectedProcess: 'codex',
|
||||
followupPrompt: null,
|
||||
launchConfig: { agent: 'codex', command: 'codex' }
|
||||
} as never,
|
||||
quickPrompt: '',
|
||||
quickTelemetry: null
|
||||
})
|
||||
|
||||
await vi.waitFor(() => expect(mocks.toastError).toHaveBeenCalledTimes(1))
|
||||
expect(mocks.ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled()
|
||||
expect(mocks.seedAgentTabState).not.toHaveBeenCalled()
|
||||
expect(mocks.ensureAgentStartup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps remote host ownership through activation and note persistence', async () => {
|
||||
runBackgroundWorktreeCreation({
|
||||
repoId: 'repo-1',
|
||||
name: 'remote-workspace',
|
||||
setupDecision: 'inherit',
|
||||
agent: null,
|
||||
pendingFirstAgentMessageRename: false,
|
||||
note: 'Remote note',
|
||||
startupPlan: null,
|
||||
quickPrompt: '',
|
||||
quickTelemetry: null
|
||||
})
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-shared', {
|
||||
sidebarRevealBehavior: 'auto',
|
||||
executionHostId: 'ssh:requested'
|
||||
})
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(mocks.store.updateWorktreeMeta).toHaveBeenCalledWith(
|
||||
'wt-shared',
|
||||
{ comment: 'Remote note' },
|
||||
{ executionHostId: 'ssh:requested' }
|
||||
)
|
||||
)
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { toast } from 'sonner'
|
||||
import { useAppStore } from '@/store'
|
||||
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
activateAndRevealWorktree,
|
||||
ensureWorktreeHasInitialTerminal,
|
||||
@@ -27,6 +27,13 @@ import {
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { seedAgentTabStateAfterWorktreeCreate } from '@/lib/worktree-creation-agent-seeds'
|
||||
import { resolveBackendDraftStartup } from '@/lib/worktree-draft-startup-view-mode'
|
||||
import { findRepoForHost } from '@/store/slices/repo-host-identity'
|
||||
import { persistCreatedWorktreeNote } from '@/lib/worktree-creation-note'
|
||||
import { preflightWorktreeCreationAgentTrust } from '@/lib/worktree-creation-agent-trust'
|
||||
import {
|
||||
canSeedCreatedWorktreeInBackground,
|
||||
hasCreatedWorktreeTerminalHandoff
|
||||
} from '@/lib/worktree-creation-terminal-handoff'
|
||||
import {
|
||||
buildWorktreeCreationStartupOpt,
|
||||
getInitialWorktreeCreationPhase,
|
||||
@@ -68,33 +75,6 @@ function revealPendingCreation(
|
||||
store.setSidebarOpen(true)
|
||||
}
|
||||
|
||||
async function preflightAgentTrust(
|
||||
request: WorktreeCreationRequest,
|
||||
path: string,
|
||||
connectionId?: string | null
|
||||
): Promise<void> {
|
||||
// Why: trust-gated agents (cursor-agent, copilot) consume the bracketed paste
|
||||
// as menu input on first launch. Pre-write the trust artifact before any
|
||||
// terminal spawns. Best-effort — the worktree already exists, so a failure
|
||||
// here must not strand it.
|
||||
if (!request.agent || !window.api.agentTrust?.markTrusted) {
|
||||
return
|
||||
}
|
||||
const preflight = TUI_AGENT_CONFIG[request.agent].preflightTrust
|
||||
if (!preflight) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await window.api.agentTrust.markTrusted({
|
||||
preset: preflight,
|
||||
workspacePath: path,
|
||||
...(connectionId ? { connectionId } : {})
|
||||
})
|
||||
} catch {
|
||||
// Best-effort: continue with launch.
|
||||
}
|
||||
}
|
||||
|
||||
async function executeWorktreeCreation(
|
||||
creationId: string,
|
||||
request: WorktreeCreationRequest
|
||||
@@ -196,9 +176,13 @@ async function executeWorktreeCreation(
|
||||
const startupOpt = buildWorktreeCreationStartupOpt(preparedRequest, backendSpawned)
|
||||
|
||||
if (worktree.path) {
|
||||
const trustState = useAppStore.getState()
|
||||
const repoConnectionId =
|
||||
useAppStore.getState().repos.find((repo) => repo.id === worktree.repoId)?.connectionId ?? null
|
||||
await preflightAgentTrust(preparedRequest, worktree.path, repoConnectionId)
|
||||
findRepoForHost(trustState.repos, worktree.repoId, {
|
||||
hostId: worktree.hostId,
|
||||
settings: trustState.settings
|
||||
})?.connectionId ?? null
|
||||
await preflightWorktreeCreationAgentTrust(preparedRequest, worktree.path, repoConnectionId)
|
||||
}
|
||||
|
||||
// `createWorktree` already inserted the real worktree row. Leaving for an app
|
||||
@@ -214,45 +198,63 @@ async function executeWorktreeCreation(
|
||||
|
||||
let activation: ActivateAndRevealResult | false = false
|
||||
let primaryTabId: string | null
|
||||
let terminalHandoffSafe = true
|
||||
if (shouldActivateOnCompletion) {
|
||||
activation = activateAndRevealWorktree(worktree.id, {
|
||||
sidebarRevealBehavior: 'auto',
|
||||
...(worktree.hostId ? { executionHostId: worktree.hostId } : {}),
|
||||
...(result.setup ? { setup: result.setup } : {}),
|
||||
...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}),
|
||||
...(startupOpt ? { startup: startupOpt } : {}),
|
||||
...(preparedRequest.issueCommand ? { issueCommand: preparedRequest.issueCommand } : {}),
|
||||
...(backendSpawned ? { backendStartupTerminalSpawned: true } : {})
|
||||
})
|
||||
terminalHandoffSafe = activation !== false
|
||||
primaryTabId = activation === false ? null : activation.primaryTabId
|
||||
} else {
|
||||
// The user moved on. Seed the worktree's terminal + setup in the background
|
||||
// (setActiveTab only writes global focus for the active worktree, so this is
|
||||
// safe) without yanking them back to it.
|
||||
primaryTabId = ensureWorktreeHasInitialTerminal(
|
||||
useAppStore.getState(),
|
||||
worktree.id,
|
||||
startupOpt,
|
||||
result.setup,
|
||||
preparedRequest.issueCommand,
|
||||
result.defaultTabs,
|
||||
{
|
||||
activateCreatedTabs: false,
|
||||
...(backendSpawned ? { backendStartupTerminalSpawned: true } : {})
|
||||
terminalHandoffSafe = canSeedCreatedWorktreeInBackground(completionState, worktree)
|
||||
if (!terminalHandoffSafe) {
|
||||
primaryTabId = null
|
||||
if (hasCreatedWorktreeTerminalHandoff(preparedRequest, result)) {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.lib.worktreeCreationFlow.backgroundStartupOwnerAmbiguous',
|
||||
'Workspace created, but automatic startup was skipped because its host is ambiguous.'
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
primaryTabId = ensureWorktreeHasInitialTerminal(
|
||||
completionState,
|
||||
worktree.id,
|
||||
startupOpt,
|
||||
result.setup,
|
||||
preparedRequest.issueCommand,
|
||||
result.defaultTabs,
|
||||
{
|
||||
activateCreatedTabs: false,
|
||||
...(backendSpawned ? { backendStartupTerminalSpawned: true } : {})
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: clearing synchronously right after activation lets React commit the
|
||||
// panel→terminal swap in one frame — no two-row flicker, no empty-terminal flash.
|
||||
useAppStore.getState().removePendingWorktreeCreation(creationId, { cleanupVm: false })
|
||||
seedAgentTabStateAfterWorktreeCreate({
|
||||
request: preparedRequest,
|
||||
worktreeId: worktree.id,
|
||||
primaryTabId,
|
||||
startupTerminalTabId: result.startupTerminal?.tabId,
|
||||
backendSpawned
|
||||
})
|
||||
if (preparedRequest.startupPlan && !backendSpawned) {
|
||||
if (terminalHandoffSafe) {
|
||||
seedAgentTabStateAfterWorktreeCreate({
|
||||
request: preparedRequest,
|
||||
worktreeId: worktree.id,
|
||||
primaryTabId,
|
||||
startupTerminalTabId: result.startupTerminal?.tabId,
|
||||
backendSpawned
|
||||
})
|
||||
}
|
||||
if (terminalHandoffSafe && preparedRequest.startupPlan && !backendSpawned) {
|
||||
void ensureAgentStartupInTerminal({
|
||||
worktreeId: worktree.id,
|
||||
primaryTabId,
|
||||
@@ -266,13 +268,7 @@ async function executeWorktreeCreation(
|
||||
// Why: awaiting the note IPC before the swap would add a visible round-trip to
|
||||
// the panel→terminal transition; it's cosmetic, so it runs last.
|
||||
if (preparedRequest.note) {
|
||||
try {
|
||||
await useAppStore.getState().updateWorktreeMeta(worktree.id, {
|
||||
comment: preparedRequest.note
|
||||
})
|
||||
} catch {
|
||||
console.error('Failed to update worktree meta after creation')
|
||||
}
|
||||
await persistCreatedWorktreeNote(worktree, preparedRequest.note)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import type { Worktree } from '../../../shared/types'
|
||||
|
||||
export async function persistCreatedWorktreeNote(
|
||||
worktree: Pick<Worktree, 'id' | 'hostId'>,
|
||||
note: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
await useAppStore
|
||||
.getState()
|
||||
.updateWorktreeMeta(
|
||||
worktree.id,
|
||||
{ comment: note },
|
||||
worktree.hostId ? { executionHostId: worktree.hostId } : undefined
|
||||
)
|
||||
} catch {
|
||||
console.error('Failed to update worktree meta after creation')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { canSeedCreatedWorktreeInBackground } from './worktree-creation-terminal-handoff'
|
||||
|
||||
function stateWithOwners(owners: { id: string; repoId: string; hostId?: string }[]): never {
|
||||
return {
|
||||
repos: owners.map((owner) => ({
|
||||
id: owner.repoId,
|
||||
connectionId: owner.hostId?.startsWith('ssh:') ? owner.hostId.slice(4) : null,
|
||||
executionHostId: owner.hostId
|
||||
})),
|
||||
worktreesByRepo: { repo: owners },
|
||||
detectedWorktreesByRepo: {},
|
||||
folderWorkspaces: [],
|
||||
projectGroups: [],
|
||||
runtimeEnvironments: [],
|
||||
settings: {}
|
||||
} as never
|
||||
}
|
||||
|
||||
describe('created worktree background terminal handoff', () => {
|
||||
it('fails closed when sibling hosts publish the same worktree id', () => {
|
||||
const id = 'repo::/workspace'
|
||||
const state = stateWithOwners([
|
||||
{ id, repoId: 'repo', hostId: 'ssh:first' },
|
||||
{ id, repoId: 'repo', hostId: 'ssh:second' }
|
||||
])
|
||||
|
||||
expect(canSeedCreatedWorktreeInBackground(state, { id, hostId: 'ssh:second' })).toBe(false)
|
||||
})
|
||||
|
||||
it('allows a uniquely resolved host and preserves legacy hostless behavior', () => {
|
||||
const id = 'repo::/workspace'
|
||||
const state = stateWithOwners([{ id, repoId: 'repo', hostId: 'ssh:requested' }])
|
||||
|
||||
expect(canSeedCreatedWorktreeInBackground(state, { id, hostId: 'ssh:requested' })).toBe(true)
|
||||
expect(canSeedCreatedWorktreeInBackground(state, { id })).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { CreateWorktreeResult, Worktree } from '../../../shared/types'
|
||||
import type { WorktreeCreationRequest } from './pending-worktree-creation'
|
||||
import { resolveExplicitWorktreeOperationRouteResult } from './worktree-operation-route'
|
||||
|
||||
type RouteState = Parameters<typeof resolveExplicitWorktreeOperationRouteResult>[0]
|
||||
|
||||
export function canSeedCreatedWorktreeInBackground(
|
||||
state: RouteState,
|
||||
worktree: Pick<Worktree, 'id' | 'hostId'>
|
||||
): boolean {
|
||||
if (!worktree.hostId) {
|
||||
return true
|
||||
}
|
||||
const resolution = resolveExplicitWorktreeOperationRouteResult(state, worktree.id)
|
||||
return resolution.kind === 'resolved' && resolution.route.executionHostId === worktree.hostId
|
||||
}
|
||||
|
||||
export function hasCreatedWorktreeTerminalHandoff(
|
||||
request: WorktreeCreationRequest,
|
||||
result: CreateWorktreeResult
|
||||
): boolean {
|
||||
return Boolean(request.startupPlan || result.setup || request.issueCommand || result.defaultTabs)
|
||||
}
|
||||
Reference in New Issue
Block a user