mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
fix(ssh): resolve the execution host in the worktree scan and managed create
The worktree scan and createManagedWorktree both picked remote-vs-local from repo.connectionId, so a row stamped only executionHostId: 'ssh:*' was scanned and created on the client against a remote path. The folder branch returns before the check, so its agent-trust write landed locally too. Refs #11163
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
// createManagedWorktree used to pick remote-vs-local from the raw `connectionId` field, so a repo
|
||||
// stamped only `executionHostId: 'ssh:*'` ran `git worktree add` on the client against a remote
|
||||
// path — and the folder branch, which returns before that check, wrote agent trust locally for a
|
||||
// remote workspace. Both are the #11163 shape: read the execution host, never one spelling of it.
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: { fromId: vi.fn(() => null) },
|
||||
webContents: { fromId: vi.fn(() => null) },
|
||||
ipcMain: { on: vi.fn(), removeListener: vi.fn() },
|
||||
app: { getPath: vi.fn(() => '/tmp'), isPackaged: false }
|
||||
}))
|
||||
|
||||
const createRuntimeFolderWorktreeMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('./runtime-folder-worktree-create', () => ({
|
||||
createRuntimeFolderWorktree: createRuntimeFolderWorktreeMock
|
||||
}))
|
||||
|
||||
const createRuntimeLocalManagedWorktreeMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('./runtime-local-worktree-create', () => ({
|
||||
createRuntimeLocalManagedWorktree: createRuntimeLocalManagedWorktreeMock
|
||||
}))
|
||||
|
||||
const trustMocks = vi.hoisted(() => ({
|
||||
local: vi.fn(async () => {}),
|
||||
remote: vi.fn(async () => {})
|
||||
}))
|
||||
vi.mock('./runtime-worktree-agent-startup', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
markLocalWorktreeTrusted: trustMocks.local,
|
||||
markRemoteWorktreeTrusted: trustMocks.remote
|
||||
}))
|
||||
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
const TARGET_ID = 'remote-1'
|
||||
const REMOTE_PATH = '/srv/app'
|
||||
|
||||
type RuntimeInternals = {
|
||||
resolveRepoSelector: (selector: string) => Promise<unknown>
|
||||
createManagedRemoteWorktree: (repo: unknown, args: unknown) => Promise<unknown>
|
||||
resolveLineageForWorktreeCreate: (input: unknown) => Promise<unknown>
|
||||
recordCreatedWorktreeLineage: (worktree: unknown, resolution: unknown) => unknown
|
||||
}
|
||||
|
||||
function makeRuntime(repo: Record<string, unknown>): {
|
||||
runtime: OrcaRuntimeService
|
||||
createRemote: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const store = {
|
||||
getSettings: () => ({ disabledTuiAgents: [], workspaceDir: '/tmp/workspaces' }),
|
||||
getProjectHostSetups: () => []
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(store as never)
|
||||
const internals = runtime as unknown as RuntimeInternals
|
||||
vi.spyOn(internals, 'resolveRepoSelector').mockResolvedValue(repo)
|
||||
vi.spyOn(internals, 'resolveLineageForWorktreeCreate').mockResolvedValue(null)
|
||||
vi.spyOn(internals, 'recordCreatedWorktreeLineage').mockReturnValue({
|
||||
lineage: null,
|
||||
workspaceLineage: null,
|
||||
warnings: []
|
||||
})
|
||||
const createRemote = vi.fn().mockResolvedValue({
|
||||
worktree: { id: 'wt-1', path: '/srv/app-feature', branch: 'feature' }
|
||||
})
|
||||
vi.spyOn(internals, 'createManagedRemoteWorktree').mockImplementation(createRemote)
|
||||
return { runtime, createRemote }
|
||||
}
|
||||
|
||||
describe('createManagedWorktree execution-host routing', () => {
|
||||
beforeEach(() => {
|
||||
createRuntimeFolderWorktreeMock.mockReset()
|
||||
createRuntimeFolderWorktreeMock.mockResolvedValue({ worktree: { id: 'folder-1' } })
|
||||
createRuntimeLocalManagedWorktreeMock.mockReset()
|
||||
// Name the defect in the failure output: reaching this mock means a remote repo was routed
|
||||
// into a client-side `git worktree add`.
|
||||
createRuntimeLocalManagedWorktreeMock.mockRejectedValue(
|
||||
new Error('local_worktree_create_ran_for_remote_repo')
|
||||
)
|
||||
trustMocks.local.mockClear()
|
||||
trustMocks.remote.mockClear()
|
||||
})
|
||||
|
||||
it('creates on the SSH host for a repo stamped executionHostId only', async () => {
|
||||
const { runtime, createRemote } = makeRuntime({
|
||||
id: 'repo-remote',
|
||||
path: REMOTE_PATH,
|
||||
kind: 'git',
|
||||
executionHostId: `ssh:${TARGET_ID}`
|
||||
})
|
||||
|
||||
await runtime.createManagedWorktree({ repoSelector: 'repo-remote', name: 'feature' } as never)
|
||||
|
||||
// A local `git worktree add` against a remote path is the silent-substitution failure.
|
||||
expect(createRuntimeLocalManagedWorktreeMock).not.toHaveBeenCalled()
|
||||
expect(createRemote).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'repo-remote', connectionId: TARGET_ID }),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('still creates on the SSH host for a legacy connectionId-only repo', async () => {
|
||||
const { runtime, createRemote } = makeRuntime({
|
||||
id: 'repo-remote',
|
||||
path: REMOTE_PATH,
|
||||
kind: 'git',
|
||||
connectionId: TARGET_ID
|
||||
})
|
||||
|
||||
await runtime.createManagedWorktree({ repoSelector: 'repo-remote', name: 'feature' } as never)
|
||||
|
||||
expect(createRuntimeLocalManagedWorktreeMock).not.toHaveBeenCalled()
|
||||
expect(createRemote).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ connectionId: TARGET_ID }),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('marks a folder workspace trusted on its SSH host, not on the client', async () => {
|
||||
const { runtime } = makeRuntime({
|
||||
id: 'repo-folder',
|
||||
path: REMOTE_PATH,
|
||||
kind: 'folder',
|
||||
connectionId: TARGET_ID,
|
||||
executionHostId: `ssh:${TARGET_ID}`
|
||||
})
|
||||
|
||||
await runtime.createManagedWorktree({ repoSelector: 'repo-folder', name: 'notes' } as never)
|
||||
|
||||
const deps = createRuntimeFolderWorktreeMock.mock.calls[0]?.[0]?.deps
|
||||
await deps.markTrusted('codex', '/srv/app')
|
||||
|
||||
expect(trustMocks.remote).toHaveBeenCalledWith('codex', TARGET_ID, '/srv/app')
|
||||
expect(trustMocks.local).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a local folder workspace trusted on the client', async () => {
|
||||
const { runtime } = makeRuntime({
|
||||
id: 'repo-folder-local',
|
||||
path: '/Users/me/notes',
|
||||
kind: 'folder'
|
||||
})
|
||||
|
||||
await runtime.createManagedWorktree({
|
||||
repoSelector: 'repo-folder-local',
|
||||
name: 'notes'
|
||||
} as never)
|
||||
|
||||
const deps = createRuntimeFolderWorktreeMock.mock.calls[0]?.[0]?.deps
|
||||
await deps.markTrusted('codex', '/Users/me/notes')
|
||||
|
||||
expect(trustMocks.local).toHaveBeenCalledWith('codex', '/Users/me/notes')
|
||||
expect(trustMocks.remote).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktre
|
||||
import type { CreateWorktreeResult } from '../../shared/worktree/create-types'
|
||||
import { isTuiAgentEnabled } from '../../shared/tui-agent-selection'
|
||||
import { isFolderRepo } from '../../shared/repo-kind'
|
||||
import { getRepoSshConnectionId } from '../../shared/execution-host'
|
||||
import { createRuntimeFolderWorktree } from './runtime-folder-worktree-create'
|
||||
import { createRuntimeLocalManagedWorktree } from './runtime-local-worktree-create'
|
||||
import { prepareRuntimeLocalWorktreeSetup } from './runtime-local-worktree-setup'
|
||||
@@ -56,7 +57,13 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork
|
||||
draftStartup?.agent ??
|
||||
(requestedAgentEnabled ? requestedAgent : undefined))
|
||||
const effectiveDraftPaste = args.startupDraftPaste ?? draftStartup?.draftPaste
|
||||
// Resolve the execution host once: SSH ownership has two spellings, and reading the raw
|
||||
// `connectionId` field routes an `executionHostId: 'ssh:*'`-only repo down the local path,
|
||||
// which runs `git worktree add` on the client against a remote path.
|
||||
const sshConnectionId = getRepoSshConnectionId(repo)
|
||||
if (isFolderRepo(repo)) {
|
||||
// A folder workspace is a registration, not a filesystem create, so it is host-agnostic —
|
||||
// except for the agent trust write, which must land on the host that will run the agent.
|
||||
return createRuntimeFolderWorktree({
|
||||
request: args,
|
||||
repo,
|
||||
@@ -68,7 +75,8 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork
|
||||
store: this.store,
|
||||
ptySpawnAvailable: Boolean(this.ptyController?.spawn),
|
||||
createTerminal: (selector, options) => this.createTerminal(selector, options),
|
||||
markTrusted: (agent, path) => this.markLocalWorkspaceTrustedForAgent(agent, path),
|
||||
markTrusted: (agent, path) =>
|
||||
this.markWorkspaceTrustedForAgent(agent, sshConnectionId, path),
|
||||
pasteDraft: (handle, draft) => this.pasteStartupDraftWhenReady(handle, draft),
|
||||
sendFollowup: (handle, followup) => this.sendStartupFollowupWhenReady(handle, followup),
|
||||
invalidateResolvedWorktrees: () => this.invalidateResolvedWorktreeCache(),
|
||||
@@ -89,15 +97,20 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork
|
||||
const lineageInput =
|
||||
args.lineage || args.comment ? { ...args.lineage, comment: args.comment } : undefined
|
||||
const lineageResolution = await this.resolveLineageForWorktreeCreate(lineageInput)
|
||||
if (repo.connectionId) {
|
||||
const result = await this.createManagedRemoteWorktree(repo, {
|
||||
...args,
|
||||
activate: args.activate,
|
||||
...(effectiveStartup ? { startup: effectiveStartup } : {}),
|
||||
...(effectiveStartupFollowup ? { startupFollowup: effectiveStartupFollowup } : {}),
|
||||
...(effectiveCreatedWithAgent ? { createdWithAgent: effectiveCreatedWithAgent } : {}),
|
||||
...(effectiveDraftPaste ? { startupDraftPaste: effectiveDraftPaste } : {})
|
||||
})
|
||||
if (sshConnectionId) {
|
||||
// Why normalize the row: the remote-create pipeline reads `repo.connectionId!` at every
|
||||
// depth, so hand it the connection the resolved host actually names.
|
||||
const result = await this.createManagedRemoteWorktree(
|
||||
{ ...repo, connectionId: sshConnectionId },
|
||||
{
|
||||
...args,
|
||||
activate: args.activate,
|
||||
...(effectiveStartup ? { startup: effectiveStartup } : {}),
|
||||
...(effectiveStartupFollowup ? { startupFollowup: effectiveStartupFollowup } : {}),
|
||||
...(effectiveCreatedWithAgent ? { createdWithAgent: effectiveCreatedWithAgent } : {}),
|
||||
...(effectiveDraftPaste ? { startupDraftPaste: effectiveDraftPaste } : {})
|
||||
}
|
||||
)
|
||||
const recordedLineage = this.recordCreatedWorktreeLineage(result.worktree, lineageResolution)
|
||||
this.emitWorktreeLifecycle({
|
||||
kind: 'created',
|
||||
|
||||
@@ -19,7 +19,7 @@ import type { Repo } from '../../shared/repo-types'
|
||||
import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime'
|
||||
import type { RuntimeWorktreeScanResult } from './repo-worktree-resolution-scan'
|
||||
import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch'
|
||||
import { getRepoExecutionHostId } from '../../shared/execution-host'
|
||||
import { getRepoExecutionHostId, getRepoSshConnectionId } from '../../shared/execution-host'
|
||||
import type { RuntimeWorktreeScanCache } from './orca-runtime-core'
|
||||
import { resolveWorktreeScanCacheTtlMs } from './runtime-worktree-scan-cache'
|
||||
|
||||
@@ -135,17 +135,21 @@ export class OrcaRuntimeWithListKnownResolvedWorktreesForExplicitTarget extends
|
||||
repo: Repo,
|
||||
projectRuntimeByRepoId?: ReadonlyMap<string, ProjectExecutionRuntimeResolution>
|
||||
): Promise<RuntimeWorktreeScanResult> {
|
||||
// Resolve the execution host, not the raw field: an `executionHostId: 'ssh:*'` row with no
|
||||
// `connectionId` would otherwise get a local project runtime and a `local:default` cache key,
|
||||
// so its scan neither routes remotely nor re-runs when the SSH provider is replaced.
|
||||
const sshConnectionId = getRepoSshConnectionId(repo)
|
||||
const projectRuntime = projectRuntimeByRepoId
|
||||
? projectRuntimeByRepoId.get(repo.id)
|
||||
: !repo.connectionId
|
||||
: !sshConnectionId
|
||||
? resolveLocalProjectRuntimeForRepo(this.requireStore(), repo)
|
||||
: undefined
|
||||
const runtimeKey = projectRuntime
|
||||
? projectRuntime.status === 'resolved'
|
||||
? projectRuntime.runtime.cacheKey
|
||||
: projectRuntime.repair.cacheKey
|
||||
: repo.connectionId
|
||||
? `ssh:${repo.connectionId}:${getSshGitProviderGeneration(repo.connectionId)}`
|
||||
: sshConnectionId
|
||||
? `ssh:${sshConnectionId}:${getSshGitProviderGeneration(sshConnectionId)}`
|
||||
: 'local:default'
|
||||
const now = Date.now()
|
||||
const scanScopeKey = `${repo.id}\0${getRepoExecutionHostId(repo)}`
|
||||
@@ -176,7 +180,7 @@ export class OrcaRuntimeWithListKnownResolvedWorktreesForExplicitTarget extends
|
||||
return this.listRepoWorktreesForResolution(repo, projectRuntimeByRepoId)
|
||||
}
|
||||
if (
|
||||
(refresh.result.ok || !repo.connectionId) &&
|
||||
(refresh.result.ok || !sshConnectionId) &&
|
||||
this.worktreeScanInFlight.get(scanScopeKey)?.promise === promise
|
||||
) {
|
||||
const entry: RuntimeWorktreeScanCache = {
|
||||
|
||||
@@ -17,7 +17,7 @@ import { getSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import type { GitWorktreeInfo } from '../../shared/worktree/types'
|
||||
import { listStoredWorktreeRowsForRepo } from './repo-worktree-row-resolution'
|
||||
import type { ResolvedWorktree } from './runtime-worktree-path-identity'
|
||||
import { getRepoExecutionHostId } from '../../shared/execution-host'
|
||||
import { getRepoExecutionHostId, getRepoSshConnectionId } from '../../shared/execution-host'
|
||||
|
||||
export class OrcaRuntimeWithRefreshRepoWorktreeScan extends OrcaRuntimeWithListKnownResolvedWorktreesForExplicitTarget {
|
||||
/**
|
||||
@@ -31,8 +31,10 @@ export class OrcaRuntimeWithRefreshRepoWorktreeScan extends OrcaRuntimeWithListK
|
||||
): Promise<RuntimeWorktreeScanRefresh> {
|
||||
const scannedAt = Date.now()
|
||||
// SSH and WSL-routed repos run Git off-host, so a local admin-dir read cannot describe them.
|
||||
// Resolve the execution host rather than reading `connectionId`: a row stamped only
|
||||
// `executionHostId: 'ssh:*'` is just as off-host, and fingerprinting it stats client paths.
|
||||
const fingerprintCapable =
|
||||
!repo.connectionId &&
|
||||
!getRepoSshConnectionId(repo) &&
|
||||
// Why: a repo whose scan TTL already reaches the reconciliation interval can never reuse a
|
||||
// fingerprint, so reading one would be pure work. Agent-scratch roots are that case today.
|
||||
resolveWorktreeScanCacheTtlMs(repo) < WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS &&
|
||||
@@ -92,13 +94,17 @@ export class OrcaRuntimeWithRefreshRepoWorktreeScan extends OrcaRuntimeWithListK
|
||||
repo: Repo,
|
||||
projectRuntime: ProjectExecutionRuntimeResolution | undefined
|
||||
): Promise<RuntimeWorktreeScanResult> {
|
||||
if (!repo.connectionId) {
|
||||
// Why not `repo.connectionId`: SSH ownership has two spellings, and a repo carrying only
|
||||
// `executionHostId: 'ssh:*'` would otherwise be scanned on the client against a remote path —
|
||||
// `git worktree list` then reports nothing, so the remote worktrees never resolve at all.
|
||||
const sshConnectionId = getRepoSshConnectionId(repo)
|
||||
if (!sshConnectionId) {
|
||||
return await scanLocalRepoWorktreesForResolution(
|
||||
repo.path,
|
||||
getLocalProjectWorktreeGitOptionsForRuntime(repo, projectRuntime)
|
||||
)
|
||||
}
|
||||
const provider = getSshGitProvider(repo.connectionId)
|
||||
const provider = getSshGitProvider(sshConnectionId)
|
||||
if (!provider) {
|
||||
return { ok: false, worktrees: this.listStoredWorktreesForResolution(repo) }
|
||||
}
|
||||
@@ -149,7 +155,7 @@ export class OrcaRuntimeWithRefreshRepoWorktreeScan extends OrcaRuntimeWithListK
|
||||
|
||||
protected invalidateSshWorktreeScanCacheInternal(targetId: string): void {
|
||||
const repos = this.store?.getRepos() ?? []
|
||||
const affectedRepos = repos.filter((repo) => repo.connectionId === targetId)
|
||||
const affectedRepos = repos.filter((repo) => getRepoSshConnectionId(repo) === targetId)
|
||||
const affectedScopeKeys = new Set(
|
||||
affectedRepos.map((repo) => `${repo.id}\0${getRepoExecutionHostId(repo)}`)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
// SSH ownership has two spellings on a repo row: the legacy `connectionId` field and the unified
|
||||
// `executionHostId: 'ssh:*'`. This suite pins the scan and the terminal launch that follows it for
|
||||
// the second spelling — the seam #17909 identified but could not test end to end (#11163).
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const electronMocks = vi.hoisted(() => {
|
||||
const ipcMain = {
|
||||
on: vi.fn(() => ipcMain),
|
||||
removeListener: vi.fn(() => ipcMain),
|
||||
emit: vi.fn(() => true)
|
||||
}
|
||||
return {
|
||||
BrowserWindow: { fromId: vi.fn((): unknown => null) },
|
||||
webContents: { fromId: vi.fn((): unknown => null) },
|
||||
ipcMain,
|
||||
app: { getPath: vi.fn(() => '/tmp'), isPackaged: false }
|
||||
}
|
||||
})
|
||||
vi.mock('electron', () => electronMocks)
|
||||
|
||||
const getSshGitProviderMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: getSshGitProviderMock,
|
||||
getSshGitProviderGeneration: vi.fn(() => 0),
|
||||
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'unavailable',
|
||||
requireSshGitProvider: (connectionId: string) => getSshGitProviderMock(connectionId)
|
||||
}))
|
||||
|
||||
const listWorktreesStrictMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../git/worktree', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
listWorktreesStrict: listWorktreesStrictMock
|
||||
}))
|
||||
|
||||
vi.mock('./repo-worktree-admin-fingerprint', () => ({
|
||||
readRepoWorktreeAdminFingerprint: vi.fn(async () => null)
|
||||
}))
|
||||
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
const TARGET_ID = 'remote-1'
|
||||
const REPO_ID = 'repo-remote'
|
||||
const REPO_PATH = '/srv/app'
|
||||
const WORKTREE_PATH = '/srv/app-feature'
|
||||
const WORKTREE_ID = `${REPO_ID}::${WORKTREE_PATH}`
|
||||
const MAIN_WORKTREE_ID = `${REPO_ID}::${REPO_PATH}`
|
||||
|
||||
function makeMeta(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
displayName: 'feature',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
linkedGitLabMR: null,
|
||||
linkedGitLabIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
/** One repo row owned by an SSH host, stamped with `executionHostId` only — no `connectionId`. */
|
||||
function makeStore(repoOverrides: Record<string, unknown>) {
|
||||
const metaById: Record<string, ReturnType<typeof makeMeta>> = {
|
||||
[WORKTREE_ID]: makeMeta({
|
||||
hostId: `ssh:${TARGET_ID}`,
|
||||
instanceId: '11111111-1111-4111-8111-111111111111'
|
||||
}),
|
||||
[MAIN_WORKTREE_ID]: makeMeta({
|
||||
displayName: 'main',
|
||||
hostId: `ssh:${TARGET_ID}`,
|
||||
instanceId: '22222222-2222-4222-8222-222222222222'
|
||||
})
|
||||
}
|
||||
const repos = [
|
||||
{
|
||||
id: REPO_ID,
|
||||
path: REPO_PATH,
|
||||
displayName: 'app',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
...repoOverrides
|
||||
}
|
||||
]
|
||||
const store = {
|
||||
getRepo: (id: string) => repos.find((repo) => repo.id === id),
|
||||
getRepos: () => repos,
|
||||
getAllWorktreeMeta: () => metaById,
|
||||
getWorktreeMeta: (id: string) => metaById[id],
|
||||
setWorktreeMeta: (id: string, meta: Record<string, unknown>) => {
|
||||
metaById[id] = { ...(metaById[id] ?? makeMeta()), ...meta } as never
|
||||
return metaById[id]
|
||||
},
|
||||
removeWorktreeMeta: () => {},
|
||||
getAllWorktreeLineage: () => ({}),
|
||||
getAllWorkspaceLineage: () => ({}),
|
||||
removeWorktreeLineage: vi.fn(),
|
||||
removeWorkspaceLineage: vi.fn(),
|
||||
getGitHubCache: () => undefined as never,
|
||||
getSettings: () => ({
|
||||
workspaceDir: '/tmp/workspaces',
|
||||
nestWorkspaces: false,
|
||||
refreshLocalBaseRefOnWorktreeCreate: false,
|
||||
branchPrefix: 'none',
|
||||
branchPrefixCustom: ''
|
||||
}),
|
||||
getProjects: () => []
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
type RuntimeInternals = {
|
||||
listResolvedWorktrees: () => Promise<{ id: string; path: string; hostId?: string }[]>
|
||||
}
|
||||
|
||||
function makeRuntime(repoOverrides: Record<string, unknown>): {
|
||||
runtime: OrcaRuntimeService
|
||||
list: () => Promise<{ id: string; path: string; hostId?: string }[]>
|
||||
} {
|
||||
const runtime = new OrcaRuntimeService(makeStore(repoOverrides) as never)
|
||||
return {
|
||||
runtime,
|
||||
list: () => (runtime as unknown as RuntimeInternals).listResolvedWorktrees()
|
||||
}
|
||||
}
|
||||
|
||||
describe('worktree scan execution-host routing', () => {
|
||||
beforeEach(() => {
|
||||
getSshGitProviderMock.mockReset()
|
||||
listWorktreesStrictMock.mockReset()
|
||||
listWorktreesStrictMock.mockResolvedValue([])
|
||||
})
|
||||
|
||||
it('scans an executionHostId-only SSH repo over its SSH provider, not on the client', async () => {
|
||||
const listWorktrees = vi.fn(async () => [
|
||||
{ path: REPO_PATH, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true },
|
||||
{ path: WORKTREE_PATH, head: 'def', branch: 'feature', isBare: false, isMainWorktree: false }
|
||||
])
|
||||
getSshGitProviderMock.mockReturnValue({ listWorktrees })
|
||||
const { list } = makeRuntime({ executionHostId: `ssh:${TARGET_ID}` })
|
||||
|
||||
const worktrees = await list()
|
||||
|
||||
expect(getSshGitProviderMock).toHaveBeenCalledWith(TARGET_ID)
|
||||
expect(listWorktrees).toHaveBeenCalledWith(REPO_PATH)
|
||||
// A client-side `git worktree list` against a remote path is the silent-substitution failure.
|
||||
expect(listWorktreesStrictMock).not.toHaveBeenCalled()
|
||||
expect(worktrees.map((worktree) => worktree.path).sort()).toEqual([REPO_PATH, WORKTREE_PATH])
|
||||
expect(worktrees.every((worktree) => worktree.hostId === `ssh:${TARGET_ID}`)).toBe(true)
|
||||
})
|
||||
|
||||
it('routes the PTY of an executionHostId-only SSH worktree to its host', async () => {
|
||||
getSshGitProviderMock.mockReturnValue({
|
||||
listWorktrees: async () => [
|
||||
{ path: REPO_PATH, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true },
|
||||
{
|
||||
path: WORKTREE_PATH,
|
||||
head: 'def',
|
||||
branch: 'feature',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
]
|
||||
})
|
||||
const { runtime } = makeRuntime({ executionHostId: `ssh:${TARGET_ID}` })
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-1' })
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
} as never)
|
||||
|
||||
await runtime.createTerminal(`id:${WORKTREE_ID}`)
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ connectionId: TARGET_ID, cwd: WORKTREE_PATH })
|
||||
)
|
||||
})
|
||||
|
||||
it('still routes a legacy connectionId-only SSH repo the same way', async () => {
|
||||
getSshGitProviderMock.mockReturnValue({
|
||||
listWorktrees: async () => [
|
||||
{ path: REPO_PATH, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true },
|
||||
{
|
||||
path: WORKTREE_PATH,
|
||||
head: 'def',
|
||||
branch: 'feature',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
]
|
||||
})
|
||||
const { runtime } = makeRuntime({ connectionId: TARGET_ID })
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-1' })
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
} as never)
|
||||
|
||||
await runtime.createTerminal(`id:${WORKTREE_ID}`)
|
||||
|
||||
expect(getSshGitProviderMock).toHaveBeenCalledWith(TARGET_ID)
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ connectionId: TARGET_ID, cwd: WORKTREE_PATH })
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user