mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
fix(runtime): route runtime filesystem commands by resolved execution host (#18325)
`ResolvedRuntimeFileTarget` carried `connectionId?: string` and no host id, so `undefined` spelled three different answers at once — "runtime: host", "unresolved" and "genuinely local". Its sole resolver read `store.getRepo(worktree.repoId)?.connectionId` and never looked at `worktree.hostId`, which outranks every repo row, so one arbitrarily chosen row decided the execution host for ~30 filesystem dispatches. This is #18307's defect in the same file family; it was deliberately left out of that PR rather than doubling an already-36-site diff. The target now carries `executionHostId: ExecutionHostId` (never null, never optional), resolved through `resolveWorktreeHostRouting` — the same adapter #18307 added — and dispatched through #18296's `resolveFilesystemRouteForHost`. Dispatch sites call `requireRuntimeFileProvider`, where `null` means exactly one thing: the host is `local` and the read happens here. Four answers that used to collapse into one: - `ssh:x` with a rival row on `ssh:y` — routes to x. Previously the first row won. - `local` with a surviving `connectionId` — a row contradicting itself; no SSH connection is handed out. - `runtime:<env>` — throws `ExecutionHostNotDispatchableError`. Its repo row's connection names a target in the *server's* namespace; reading it here reaches a same-named target on this client. - rival rows disagreeing with no worktree host — `worktree_execution_host_unresolved`, matching the launch and Git paths rather than guessing a row. Two further reads stop degrading. `assertRuntimeFileMutationExpectation` recomputed the host from `connectionId`, so a client's host expectation could pass against a host the workspace never named; it now compares the resolved host. And the cross-workspace terminal tap coalesced `knownWorkspaceTarget?.connectionId ?? connectionId`, so a sibling workspace resolved as `local` inherited the origin worktree's SSH target and statted a local path on the remote box; a non-optional host id replaces rather than coalesces. An unreachable SSH host still throws `SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE`; loss of contact is never evidence of locality (docs/reference/ssh-execution-boundary.md). Quick-open listing and path search keep degrading to empty for an unreachable host — that is a false negative, not a local answer — and now do so only for a host that really is remote. The whole `runtime-file-commands-*` family carries `@ts-nocheck` from a mechanical class split, so removing the field could not raise the compile errors that made #18307 safe. `runtime-file-command-target.ts` is deliberately checked, and a ratchet test stands in for the errors the family cannot produce. No wire change: `ResolvedRuntimeFileTarget` is main-process internal, and the SSH watcher-release and grant keys are byte-identical to before.
This commit is contained in:
@@ -50,7 +50,8 @@ function createRuntimeCommands(): RuntimeFileCommands {
|
||||
return new RuntimeFileCommands({
|
||||
requireStore: () => store,
|
||||
resolveRuntimeFileTarget: async () => ({
|
||||
worktree: { id: 'wt-1', repoId: 'repo-1', path: REPO_PATH }
|
||||
worktree: { id: 'wt-1', repoId: 'repo-1', path: REPO_PATH },
|
||||
executionHostId: 'local'
|
||||
})
|
||||
} as never)
|
||||
}
|
||||
|
||||
@@ -29,8 +29,8 @@ export class OrcaRuntimeWithFileCommands extends OrcaRuntimeWithPreservedBranchC
|
||||
requireStore: () => this.requireStore(),
|
||||
resolveWorktreeSelector: (selector) => this.resolveWorktreeSelector(selector),
|
||||
resolveRuntimeFileTarget: (selector) => this.resolveRuntimeFileTarget(selector),
|
||||
resolveKnownWorkspaceFileTarget: (absolutePath, connectionId) =>
|
||||
this.resolveKnownWorkspaceFileTarget(absolutePath, connectionId),
|
||||
resolveKnownWorkspaceFileTarget: (absolutePath, executionHostId) =>
|
||||
this.resolveKnownWorkspaceFileTarget(absolutePath, executionHostId),
|
||||
resolveTerminalCwd: (terminalHandle) => this.resolveTerminalCwd(terminalHandle),
|
||||
resolveTerminalContext: (terminalHandle) => this.resolveTerminalContext(terminalHandle),
|
||||
resolveTerminalFileUriHostname: (terminalHandle) =>
|
||||
|
||||
@@ -154,7 +154,7 @@ describe('RuntimeFileCommands', () => {
|
||||
repoId: 'repo-1',
|
||||
path: '/remote/repo'
|
||||
},
|
||||
connectionId: 'ssh-1'
|
||||
executionHostId: 'ssh:ssh-1'
|
||||
}))
|
||||
const { commands } = createRuntimeFileCommands({
|
||||
openFile,
|
||||
|
||||
@@ -105,11 +105,20 @@ export const filesystemSearchGitMock = {
|
||||
searchWithGitGrep: searchWithGitGrepMock
|
||||
}
|
||||
|
||||
const SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE =
|
||||
'Remote connection dropped. Click Reconnect on the SSH target before retrying.'
|
||||
|
||||
export const sshFilesystemDispatchMock = {
|
||||
getSshFilesystemProvider: getSshFilesystemProviderMock,
|
||||
requireSshFilesystemProvider: (connectionId: string) => {
|
||||
const provider = getSshFilesystemProviderMock(connectionId)
|
||||
if (!provider) {
|
||||
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
return provider
|
||||
},
|
||||
onSshFilesystemProviderRegistered: () => () => undefined,
|
||||
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE:
|
||||
'Remote connection dropped. Click Reconnect on the SSH target before retrying.'
|
||||
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE
|
||||
}
|
||||
|
||||
export function resetRuntimeFileMocks(): void {
|
||||
|
||||
@@ -75,7 +75,7 @@ describe('RuntimeFileCommands', () => {
|
||||
const { commands } = createRuntimeFileCommands({
|
||||
resolveRuntimeFileTarget: vi.fn(async () => ({
|
||||
worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo' },
|
||||
connectionId: 'ssh-1'
|
||||
executionHostId: 'ssh:ssh-1'
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -96,7 +96,7 @@ describe('RuntimeFileCommands', () => {
|
||||
repoId: 'repo-1',
|
||||
path: '/repo'
|
||||
},
|
||||
connectionId: null
|
||||
executionHostId: 'local'
|
||||
}))
|
||||
const { commands } = createRuntimeFileCommands({ resolveRuntimeFileTarget })
|
||||
const child = createRuntimeSearchChild()
|
||||
@@ -128,7 +128,7 @@ describe('RuntimeFileCommands', () => {
|
||||
async (order) => {
|
||||
const resolveRuntimeFileTarget = vi.fn(async () => ({
|
||||
worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo' },
|
||||
connectionId: null
|
||||
executionHostId: 'local'
|
||||
}))
|
||||
const { commands } = createRuntimeFileCommands({ resolveRuntimeFileTarget })
|
||||
const child = createRuntimeSearchChild()
|
||||
@@ -163,7 +163,7 @@ describe('RuntimeFileCommands', () => {
|
||||
it("falls back when a runtime native launcher exits outside ripgrep's contract", async () => {
|
||||
const resolveRuntimeFileTarget = vi.fn(async () => ({
|
||||
worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo' },
|
||||
connectionId: null
|
||||
executionHostId: 'local'
|
||||
}))
|
||||
const { commands } = createRuntimeFileCommands({ resolveRuntimeFileTarget })
|
||||
const child = createRuntimeSearchChild()
|
||||
@@ -191,7 +191,7 @@ describe('RuntimeFileCommands', () => {
|
||||
repoId: 'repo-1',
|
||||
path: 'C:\\repo'
|
||||
},
|
||||
connectionId: null
|
||||
executionHostId: 'local'
|
||||
}))
|
||||
const { commands, store } = createRuntimeFileCommands({ resolveRuntimeFileTarget })
|
||||
const child = createRuntimeSearchChild()
|
||||
@@ -231,7 +231,7 @@ describe('RuntimeFileCommands', () => {
|
||||
it('keeps the runtime WSL preflight and falls back before starting real rg', async () => {
|
||||
const resolveRuntimeFileTarget = vi.fn(async () => ({
|
||||
worktree: { id: 'wt-1', repoId: 'repo-1', path: 'C:\\repo' },
|
||||
connectionId: null
|
||||
executionHostId: 'local'
|
||||
}))
|
||||
const { commands } = createRuntimeFileCommands({ resolveRuntimeFileTarget })
|
||||
const fallback = { files: [], totalMatches: 0, truncated: false }
|
||||
@@ -250,7 +250,7 @@ describe('RuntimeFileCommands', () => {
|
||||
it('keeps legacy SSH Quick Open replies within the frame-sized result bound', async () => {
|
||||
const resolveRuntimeFileTarget = vi.fn(async () => ({
|
||||
worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo' },
|
||||
connectionId: 'ssh-1'
|
||||
executionHostId: 'ssh:ssh-1'
|
||||
}))
|
||||
const { commands } = createRuntimeFileCommands({ resolveRuntimeFileTarget })
|
||||
const listFiles = vi.fn(async () => ['src/target.ts'])
|
||||
|
||||
@@ -67,7 +67,7 @@ function sshCommands() {
|
||||
path: '/remote/repo',
|
||||
resolveRuntimeFileTarget: vi.fn(async () => ({
|
||||
worktree: { id: 'wt-1', repoId: 'repo-1', path: '/remote/repo' },
|
||||
connectionId: 'ssh-1'
|
||||
executionHostId: 'ssh:ssh-1'
|
||||
}))
|
||||
}).commands
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ function createRuntimeFileCommands(): RuntimeFileCommands {
|
||||
resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1', repoId: 'repo-1', path: ROOT_PATH })),
|
||||
resolveRuntimeFileTarget: vi.fn(async () => ({
|
||||
worktree: { id: 'wt-1', repoId: 'repo-1', path: ROOT_PATH },
|
||||
connectionId: CONNECTION_ID
|
||||
executionHostId: `ssh:${CONNECTION_ID}`
|
||||
})),
|
||||
resolveRuntimeGitTarget: vi.fn(),
|
||||
openFile: vi.fn()
|
||||
|
||||
@@ -103,6 +103,7 @@ describe('RuntimeFileCommands', () => {
|
||||
}
|
||||
const resolveKnownWorkspaceFileTarget = vi.fn(async () => ({
|
||||
worktree: sibling,
|
||||
executionHostId: 'local',
|
||||
relativePath: 'docs/readme.md'
|
||||
}))
|
||||
const { commands } = createRuntimeFileCommands({
|
||||
@@ -154,6 +155,7 @@ describe('RuntimeFileCommands', () => {
|
||||
}
|
||||
const resolveKnownWorkspaceFileTarget = vi.fn(async () => ({
|
||||
worktree: sibling,
|
||||
executionHostId: 'local',
|
||||
relativePath: ''
|
||||
}))
|
||||
const hasRecentTerminalOutputPath = vi.fn(() => true)
|
||||
@@ -195,7 +197,7 @@ describe('RuntimeFileCommands', () => {
|
||||
}
|
||||
const resolveKnownWorkspaceFileTarget = vi.fn(async () => ({
|
||||
worktree: sibling,
|
||||
connectionId: 'ssh-1',
|
||||
executionHostId: 'ssh:ssh-1',
|
||||
relativePath: 'docs/readme.md'
|
||||
}))
|
||||
const { commands, store } = createRuntimeFileCommands({
|
||||
@@ -245,7 +247,7 @@ describe('RuntimeFileCommands', () => {
|
||||
}
|
||||
const resolveKnownWorkspaceFileTarget = vi.fn(async () => ({
|
||||
worktree: sibling,
|
||||
connectionId: 'ssh-1',
|
||||
executionHostId: 'ssh:ssh-1',
|
||||
relativePath: ''
|
||||
}))
|
||||
const hasRecentTerminalOutputPath = vi.fn(() => true)
|
||||
@@ -274,11 +276,14 @@ describe('RuntimeFileCommands', () => {
|
||||
expect(hasRecentTerminalOutputPath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// The host was `runtime:env-a` until this process stopped dispatching runtime hosts at all
|
||||
// (see runtime-file-target-execution-host.test.ts); an SSH host proves the same scoping on a
|
||||
// host this process actually serves.
|
||||
it('scopes sibling lookup to the selected worktree execution host', async () => {
|
||||
const resolveKnownWorkspaceFileTarget = vi.fn(async () => null)
|
||||
const { commands } = createRuntimeFileCommands({
|
||||
path: '/repo-a',
|
||||
hostId: 'runtime:env-a',
|
||||
hostId: 'ssh:openclaw',
|
||||
resolveKnownWorkspaceFileTarget
|
||||
})
|
||||
|
||||
@@ -293,7 +298,7 @@ describe('RuntimeFileCommands', () => {
|
||||
|
||||
expect(resolveKnownWorkspaceFileTarget).toHaveBeenCalledWith(
|
||||
'/repo-b/docs/readme.md',
|
||||
'runtime:env-a'
|
||||
'ssh:openclaw'
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -2,6 +2,12 @@ import { afterEach, beforeEach, vi } from 'vitest'
|
||||
import { awaitRuntimeFileWatcherUnsubscribes, RuntimeFileCommands } from './orca-runtime-files'
|
||||
import { resetSshConnectionGenerations } from '../ssh/ssh-connection-generation'
|
||||
import { resetRuntimeFileMocks } from './orca-runtime-files-mock-registry'
|
||||
import {
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
normalizeExecutionHostId,
|
||||
toSshExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../shared/execution-host'
|
||||
|
||||
/** Restores the shared fs/auth/watcher mock state and fake timers around each test. */
|
||||
export function useRuntimeFileCommandsLifecycle(): void {
|
||||
@@ -51,6 +57,14 @@ export function createRuntimeFileCommands(options?: {
|
||||
path,
|
||||
...(options?.hostId ? { hostId: options.hostId } : {})
|
||||
}
|
||||
// Mirrors the real resolver: the worktree's own host outranks the repo row.
|
||||
const runtimeFileTargetExecutionHostId = (): ExecutionHostId => {
|
||||
const connectionId = store.getRepo(worktree.repoId)?.connectionId
|
||||
return (
|
||||
normalizeExecutionHostId(options?.hostId) ??
|
||||
(connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID)
|
||||
)
|
||||
}
|
||||
const commands = new RuntimeFileCommands({
|
||||
getRuntimeId: () => 'runtime-1',
|
||||
requireStore: () => store,
|
||||
@@ -59,7 +73,7 @@ export function createRuntimeFileCommands(options?: {
|
||||
options?.resolveRuntimeFileTarget ??
|
||||
vi.fn(async () => ({
|
||||
worktree,
|
||||
connectionId: store.getRepo(worktree.repoId)?.connectionId
|
||||
executionHostId: runtimeFileTargetExecutionHostId()
|
||||
})),
|
||||
...(options?.resolveKnownWorkspaceFileTarget
|
||||
? { resolveKnownWorkspaceFileTarget: options.resolveKnownWorkspaceFileTarget }
|
||||
|
||||
@@ -87,7 +87,8 @@ function createRuntimeFileCommands(rootPath: string) {
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
path: rootPath
|
||||
}
|
||||
},
|
||||
executionHostId: 'local'
|
||||
})),
|
||||
resolveRuntimeGitTarget: vi.fn(),
|
||||
openFile: vi.fn()
|
||||
@@ -560,7 +561,7 @@ describe('RuntimeFileCommands file watching', () => {
|
||||
repoId: 'repo-1',
|
||||
path: '/remote/repo'
|
||||
},
|
||||
connectionId: 'ssh-1'
|
||||
executionHostId: 'ssh:ssh-1'
|
||||
})),
|
||||
resolveRuntimeGitTarget: vi.fn(),
|
||||
openFile: vi.fn()
|
||||
|
||||
@@ -6,8 +6,7 @@ export { WINDOWS_RUNTIME_FILE_WATCH_CLOSE_DEADLINE_MS } from './runtime-file-com
|
||||
export { awaitRuntimeFileWatcherUnsubscribes } from './runtime-file-watcher-leases'
|
||||
export { _getRuntimeFileWatcherReleaseCountForTests } from './runtime-file-watcher-leases'
|
||||
export { _resetRuntimeFileWatcherLeasesForTests } from './runtime-file-watcher-leases'
|
||||
export type { ResolvedRuntimeFileWorktree } from './runtime-file-watcher-leases'
|
||||
export type { ResolvedRuntimeFileTarget } from './runtime-file-watcher-leases'
|
||||
export { getRuntimeFileTargetExecutionHostId } from './runtime-file-watcher-leases'
|
||||
export type { ResolvedRuntimeFileWorktree } from './runtime-file-command-target'
|
||||
export type { ResolvedRuntimeFileTarget } from './runtime-file-command-target'
|
||||
export type { RuntimeFileCommandHost } from './runtime-file-command-host'
|
||||
export { isSafeMobileRelativePath } from './runtime-file-command-host'
|
||||
|
||||
@@ -8,7 +8,11 @@ import type {
|
||||
} from '../../shared/runtime-types'
|
||||
import type { ResolvedWorktree } from './runtime-worktree-path-identity'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host'
|
||||
import {
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
toSshExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../shared/execution-host'
|
||||
import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options'
|
||||
import { resolveWorktreeHostRouting } from './worktree-launch-host-repo'
|
||||
|
||||
@@ -186,21 +190,34 @@ export class OrcaRuntimeWithPersistHeadlessTerminalTitle extends OrcaRuntimeWith
|
||||
return { worktree, repo, executionHostId, localGitOptions }
|
||||
}
|
||||
|
||||
// Why: same defect as `resolveRuntimeGitTarget` above, in ~30 filesystem dispatches. `getRepo(id)`
|
||||
// is host-blind and never read `worktree.hostId`, and the `connectionId` it returned spelled
|
||||
// "runtime host", "unresolved" and "genuinely local" all as `undefined` (#11163).
|
||||
protected async resolveRuntimeFileTarget(worktreeSelector: string): Promise<{
|
||||
worktree: ResolvedWorktree
|
||||
connectionId?: string
|
||||
executionHostId: ExecutionHostId
|
||||
}> {
|
||||
const folderScope = await this.resolveFolderWorkspaceLaunchScope(worktreeSelector)
|
||||
if (folderScope?.folderWorkspace) {
|
||||
// A folder workspace has no repo row to disagree with; its own inference already threw on an
|
||||
// ambiguous one, and it is never hosted by a runtime environment.
|
||||
return {
|
||||
worktree: this.folderWorkspaceToResolvedWorktree(folderScope.folderWorkspace),
|
||||
connectionId: folderScope.connectionId ?? undefined
|
||||
executionHostId: folderScope.connectionId
|
||||
? toSshExecutionHostId(folderScope.connectionId)
|
||||
: LOCAL_EXECUTION_HOST_ID
|
||||
}
|
||||
}
|
||||
|
||||
const store = this.requireStore()
|
||||
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
|
||||
const repo = store.getRepo(worktree.repoId)
|
||||
return { worktree, connectionId: repo?.connectionId ?? undefined }
|
||||
const routing = resolveWorktreeHostRouting(store.getRepos(), worktree)
|
||||
if (routing.kind === 'ambiguous') {
|
||||
throw new Error('worktree_execution_host_unresolved')
|
||||
}
|
||||
return {
|
||||
worktree,
|
||||
executionHostId: routing.kind === 'resolved' ? routing.hostId : LOCAL_EXECUTION_HOST_ID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
||||
import { OrcaRuntimeWithPersistHeadlessTerminalTitle } from './orca-runtime-persist-headless-terminal-title'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import {
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
toSshExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../shared/execution-host'
|
||||
import type { ResolvedWorktree } from './runtime-worktree-path-identity'
|
||||
import { getRuntimeFileTargetExecutionHostId } from './orca-runtime-files'
|
||||
import { resolveWorktreeHostRouting } from './worktree-launch-host-repo'
|
||||
import { findRuntimeWorkspaceFileOwner } from '../../shared/runtime-workspace-file-owner'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
@@ -14,17 +18,14 @@ export class OrcaRuntimeWithResolveKnownWorkspaceFileTarget extends OrcaRuntimeW
|
||||
executionHostId: ExecutionHostId
|
||||
): Promise<{
|
||||
worktree: ResolvedWorktree
|
||||
connectionId?: string
|
||||
executionHostId: ExecutionHostId
|
||||
relativePath: string
|
||||
} | null> {
|
||||
const targets = new Map<
|
||||
string,
|
||||
{
|
||||
worktree: ResolvedWorktree
|
||||
connectionId?: string
|
||||
executionHostId: ExecutionHostId
|
||||
}
|
||||
{ worktree: ResolvedWorktree; executionHostId: ExecutionHostId }
|
||||
>()
|
||||
const repos = this.store?.getRepos() ?? []
|
||||
const resolvedWorktrees = await this.listResolvedWorktrees()
|
||||
const visibilitySourceMatchersByRepoId =
|
||||
this.buildRuntimeVisibilitySourceMatchersByRepoId(resolvedWorktrees)
|
||||
@@ -37,29 +38,28 @@ export class OrcaRuntimeWithResolveKnownWorkspaceFileTarget extends OrcaRuntimeW
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const candidateConnectionId = this.store?.getRepo(worktree.repoId)?.connectionId ?? undefined
|
||||
// Why: `getRepo(id)` is host-blind, so a candidate on one SSH host could be filed under
|
||||
// another's key and then answer for a path it does not hold. Rival rows that disagree with
|
||||
// no worktree host name no single filesystem authority, so that candidate is dropped.
|
||||
const routing = resolveWorktreeHostRouting(repos, worktree)
|
||||
if (routing.kind === 'ambiguous') {
|
||||
continue
|
||||
}
|
||||
const target = {
|
||||
worktree,
|
||||
executionHostId: getRuntimeFileTargetExecutionHostId({
|
||||
worktree,
|
||||
connectionId: candidateConnectionId
|
||||
}),
|
||||
...(candidateConnectionId ? { connectionId: candidateConnectionId } : {})
|
||||
executionHostId: routing.kind === 'resolved' ? routing.hostId : LOCAL_EXECUTION_HOST_ID
|
||||
}
|
||||
targets.set(`${target.executionHostId}\0${worktree.id}`, target)
|
||||
}
|
||||
for (const folderWorkspace of this.store?.getFolderWorkspaces?.() ?? []) {
|
||||
try {
|
||||
const candidateConnectionId =
|
||||
this.resolveFolderWorkspaceConnectionId(folderWorkspace) ?? undefined
|
||||
const candidateConnectionId = this.resolveFolderWorkspaceConnectionId(folderWorkspace)
|
||||
const worktree = this.folderWorkspaceToResolvedWorktree(folderWorkspace)
|
||||
const target = {
|
||||
worktree,
|
||||
executionHostId: getRuntimeFileTargetExecutionHostId({
|
||||
worktree,
|
||||
connectionId: candidateConnectionId
|
||||
}),
|
||||
...(candidateConnectionId ? { connectionId: candidateConnectionId } : {})
|
||||
executionHostId: candidateConnectionId
|
||||
? toSshExecutionHostId(candidateConnectionId)
|
||||
: LOCAL_EXECUTION_HOST_ID
|
||||
}
|
||||
targets.set(`${target.executionHostId}\0${worktree.id}`, target)
|
||||
} catch {
|
||||
|
||||
@@ -7,7 +7,6 @@ import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
||||
import type { AgentStatusIpcPayload } from '../../shared/agent-status-types'
|
||||
import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options'
|
||||
import { getRuntimeFileTargetExecutionHostId } from './orca-runtime-files'
|
||||
import type { AgentSessionAttachParams } from '../native-chat/agent-session-wire/structured-agent-session-attach'
|
||||
import { getSystemCodexHomePath } from '../codex/codex-home-paths'
|
||||
import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults'
|
||||
@@ -90,18 +89,16 @@ export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends Orca
|
||||
protected async resolveStructuredAgentSessionLocation(worktreeSelector: string) {
|
||||
const target = await this.resolveRuntimeFileTarget(worktreeSelector)
|
||||
const repo = this.store?.getRepo(target.worktree.repoId)
|
||||
// WSL routing describes *this* machine; no remote or runtime host may inherit it.
|
||||
const wslDistro =
|
||||
repo && !target.connectionId
|
||||
repo && target.executionHostId === LOCAL_EXECUTION_HOST_ID
|
||||
? (getLocalProjectWorktreeGitOptions(this.requireStore(), repo).wslDistro ?? null)
|
||||
: null
|
||||
const folderWorkspace = this.store
|
||||
?.getFolderWorkspaces?.()
|
||||
.some((workspace) => workspace.id === target.worktree.id)
|
||||
return {
|
||||
executionHostId: getRuntimeFileTargetExecutionHostId({
|
||||
worktree: target.worktree,
|
||||
connectionId: target.connectionId
|
||||
}),
|
||||
executionHostId: target.executionHostId,
|
||||
wslDistro,
|
||||
workspaceId: target.worktree.id,
|
||||
workspaceKind: folderWorkspace ? ('folder' as const) : ('git-worktree' as const)
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Store } from '../persistence'
|
||||
import type {
|
||||
ResolvedRuntimeFileTarget,
|
||||
ResolvedRuntimeFileWorktree
|
||||
} from './runtime-file-watcher-leases'
|
||||
} from './runtime-file-command-target'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { RuntimeNativeChatFileContext } from '../../shared/runtime-types'
|
||||
import type { FsChangeEvent } from '../../shared/filesystem-entry-types'
|
||||
@@ -42,8 +42,9 @@ export type RuntimeFileCommandHost = {
|
||||
pathText: string,
|
||||
absolutePath: string
|
||||
): boolean | Promise<boolean>
|
||||
// `executionHostId`, not `connectionId`: a repo row's connection cannot tell `runtime:` from
|
||||
// `local`, and this contract must not re-introduce that spelling. See runtime-git-command-target.
|
||||
// `executionHostId`, not `connectionId`, on both target contracts: a repo row's connection cannot
|
||||
// tell `runtime:` from `local`, and neither may re-introduce that spelling. See
|
||||
// runtime-git-command-target and runtime-file-command-target.
|
||||
resolveRuntimeGitTarget(
|
||||
selector: string
|
||||
): Promise<{ worktree: ResolvedRuntimeFileWorktree; executionHostId: ExecutionHostId }>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types'
|
||||
import {
|
||||
ExecutionHostNotDispatchableError,
|
||||
resolveFilesystemRouteForHost
|
||||
} from '../providers/execution-host-provider-dispatch'
|
||||
import { SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE } from '../providers/ssh-filesystem-dispatch'
|
||||
import type { IFilesystemProvider } from '../providers/types'
|
||||
|
||||
export type ResolvedRuntimeFileWorktree = Worktree & { git: GitWorktreeInfo }
|
||||
|
||||
export type ResolvedRuntimeFileTarget = {
|
||||
worktree: ResolvedRuntimeFileWorktree
|
||||
/**
|
||||
* The host whose filesystem holds this workspace. Never optional and never null: the field it
|
||||
* replaced (`connectionId?: string`) spelled "runtime host", "unresolved" and "genuinely local"
|
||||
* all as `undefined`, so every path that could not resolve answered "local" and read remote
|
||||
* paths on the client (#11163). Unresolved now fails at resolution time instead of arriving here
|
||||
* as a silently-local target. Mirrors `RuntimeGitTarget.executionHostId`.
|
||||
*/
|
||||
executionHostId: ExecutionHostId
|
||||
}
|
||||
|
||||
/** A workspace-relative path already joined onto its host's root; `executionHostId` routes it. */
|
||||
export type RuntimeFileExplorerPath = {
|
||||
worktree: ResolvedRuntimeFileWorktree
|
||||
path: string
|
||||
executionHostId: ExecutionHostId
|
||||
}
|
||||
|
||||
/**
|
||||
* The two hosts this process can itself run a runtime filesystem command on, narrowed from the
|
||||
* shared host-keyed route in `src/main/providers/execution-host-provider-dispatch.ts`.
|
||||
*
|
||||
* `runtime:<env>` is deliberately not a variant, for the same reason it is not one for Git: the
|
||||
* files live on that environment's own server, which normalizes the call to its own `local`, and
|
||||
* the SSH target on its repo row is that server's *nested* one — addressable only as the pair
|
||||
* (environmentId, targetId). Handing that id to this client's SSH table reads a same-named target
|
||||
* in the wrong namespace, so it throws rather than routing.
|
||||
*/
|
||||
export type RuntimeFileRoute =
|
||||
| { kind: 'local' }
|
||||
/** `provider: null` is "remote and currently unreachable" — never "read it here". */
|
||||
| { kind: 'ssh'; connectionId: string; provider: IFilesystemProvider | null }
|
||||
|
||||
/** The remote half of the route, for leaf helpers that only ever run against an SSH host. */
|
||||
export type RuntimeFileSshRoute = Extract<RuntimeFileRoute, { kind: 'ssh' }>
|
||||
|
||||
export function runtimeFileRouteForTarget(target: {
|
||||
executionHostId: ExecutionHostId
|
||||
}): RuntimeFileRoute {
|
||||
const route = resolveFilesystemRouteForHost(target.executionHostId)
|
||||
switch (route.kind) {
|
||||
case 'local':
|
||||
return { kind: 'local' }
|
||||
case 'ssh':
|
||||
return { kind: 'ssh', connectionId: route.connectionId, provider: route.provider }
|
||||
case 'runtime':
|
||||
throw new ExecutionHostNotDispatchableError(route.hostId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `null` means exactly one thing: the host is `local`, and this command reads and writes here. An
|
||||
* unreachable SSH host and a `runtime:` host both throw.
|
||||
*/
|
||||
export function requireRuntimeFileProvider(target: {
|
||||
executionHostId: ExecutionHostId
|
||||
}): IFilesystemProvider | null {
|
||||
const route = runtimeFileRouteForTarget(target)
|
||||
if (route.kind === 'local') {
|
||||
return null
|
||||
}
|
||||
if (!route.provider) {
|
||||
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
return route.provider
|
||||
}
|
||||
|
||||
/**
|
||||
* The SSH target id for the leaf helpers that still address a connection by name — watcher release
|
||||
* keys, re-arm registration, remote path stats. `undefined` is `local`; a `runtime:` host throws
|
||||
* rather than surrendering its nested target id to this client's namespace.
|
||||
*/
|
||||
export function runtimeFileSshTargetId(target: {
|
||||
executionHostId: ExecutionHostId
|
||||
}): string | undefined {
|
||||
const route = runtimeFileRouteForTarget(target)
|
||||
return route.kind === 'ssh' ? route.connectionId : undefined
|
||||
}
|
||||
+21
-12
@@ -10,6 +10,11 @@ import {
|
||||
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE,
|
||||
getSshFilesystemProvider
|
||||
} from '../providers/ssh-filesystem-dispatch'
|
||||
import {
|
||||
requireRuntimeFileProvider,
|
||||
runtimeFileRouteForTarget,
|
||||
runtimeFileSshTargetId
|
||||
} from './runtime-file-command-target'
|
||||
import { readdir, stat } from 'node:fs/promises'
|
||||
import type { DirEntry, FsChangeEvent } from '../../shared/filesystem-entry-types'
|
||||
import { sortDirEntries } from '../../shared/file-name-sort'
|
||||
@@ -56,11 +61,8 @@ export class RuntimeFileCommandsWithAssertRemoteTerminalFileGrantPathStillCanoni
|
||||
|
||||
async readFileExplorerDir(worktreeSelector: string, relativePath: string): Promise<DirEntry[]> {
|
||||
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
||||
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
const provider = requireRuntimeFileProvider(target)
|
||||
if (provider) {
|
||||
// Why: re-sort locally — the remote relay may be an older build with
|
||||
// lexicographic ordering.
|
||||
return sortDirEntries(await provider.readDir(target.path))
|
||||
@@ -83,22 +85,29 @@ export class RuntimeFileCommandsWithAssertRemoteTerminalFileGrantPathStillCanoni
|
||||
signal?: AbortSignal
|
||||
): Promise<() => Promise<void>> {
|
||||
const target = await this.resolveFileExplorerPath(worktreeSelector, '')
|
||||
// Why: watcher keys must scope teardown to the owning host; a `runtime:` host throws here
|
||||
// rather than registering a lease under this client's namespace.
|
||||
const sshTargetId = runtimeFileSshTargetId(target)
|
||||
const open = async (): Promise<{
|
||||
unsubscribe: () => Promise<void>
|
||||
rootPaths: string[]
|
||||
}> => {
|
||||
const finishInstall = beginWatcherInstall(target.path, target.connectionId)
|
||||
const finishInstall = beginWatcherInstall(target.path, sshTargetId)
|
||||
try {
|
||||
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
// Re-resolved per open: a reconnect mints a fresh provider for the same target.
|
||||
const route = runtimeFileRouteForTarget(target)
|
||||
if (route.kind === 'ssh') {
|
||||
if (!route.provider) {
|
||||
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
// Why: the RPC layer already threads AbortSignal for local watches; SSH must cancel the remote fs.watch, not wait it out.
|
||||
const close = await provider.watch(target.path, callback, { signal, onTerminalError })
|
||||
const close = await route.provider.watch(target.path, callback, {
|
||||
signal,
|
||||
onTerminalError
|
||||
})
|
||||
const rearm = armSshFileExplorerWatchRearm({
|
||||
runtimeId: this.host.getRuntimeId(),
|
||||
connectionId: target.connectionId,
|
||||
connectionId: route.connectionId,
|
||||
rootPath: target.path,
|
||||
callback,
|
||||
onTerminalError,
|
||||
@@ -132,7 +141,7 @@ export class RuntimeFileCommandsWithAssertRemoteTerminalFileGrantPathStillCanoni
|
||||
const initial = await open()
|
||||
return registerRuntimeFileWatcherRelease(
|
||||
this.host.getRuntimeId(),
|
||||
target.connectionId,
|
||||
sshTargetId,
|
||||
initial.rootPaths,
|
||||
initial.unsubscribe,
|
||||
async () => (await open()).unsubscribe,
|
||||
|
||||
@@ -24,6 +24,7 @@ import { stat } from 'node:fs/promises'
|
||||
import { joinWorktreeRelativePath } from './runtime-relative-paths'
|
||||
import { resolveAuthorizedPath } from '../ipc/filesystem-auth'
|
||||
import { isENOENT } from '../ipc/filesystem-path-containment'
|
||||
import { runtimeFileRouteForTarget, type RuntimeFileRoute } from './runtime-file-command-target'
|
||||
|
||||
export class RuntimeFileCommandsWithConstructor extends RuntimeFileCommandsWithActiveRuntimeTextSearches {
|
||||
constructor(private readonly host: RuntimeFileCommandHost) {
|
||||
@@ -36,10 +37,12 @@ export class RuntimeFileCommandsWithConstructor extends RuntimeFileCommandsWithA
|
||||
): Promise<RuntimeFileListResult> {
|
||||
const store = this.host.requireStore()
|
||||
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
|
||||
const { worktree, connectionId } = target
|
||||
const files = connectionId
|
||||
? await this.listRemoteMobileFiles(worktree.path, connectionId, undefined, options.signal)
|
||||
: await listQuickOpenFiles(worktree.path, store, undefined, options.signal)
|
||||
const { worktree } = target
|
||||
const route = runtimeFileRouteForTarget(target)
|
||||
const files =
|
||||
route.kind === 'ssh'
|
||||
? await this.listRemoteMobileFiles(worktree.path, route.provider, undefined, options.signal)
|
||||
: await listQuickOpenFiles(worktree.path, store, undefined, options.signal)
|
||||
const entries = files
|
||||
.filter((relativePath) => isSafeMobileRelativePath(relativePath))
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
@@ -66,22 +69,26 @@ export class RuntimeFileCommandsWithConstructor extends RuntimeFileCommandsWithA
|
||||
): Promise<RuntimeFileListResult> {
|
||||
const store = this.host.requireStore()
|
||||
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
|
||||
const { worktree, connectionId } = target
|
||||
const cacheKey = `${connectionId ?? 'local'}:${worktree.id}:${worktree.path}`
|
||||
const { worktree } = target
|
||||
const route = runtimeFileRouteForTarget(target)
|
||||
// Why: identical paths exist on local and on several SSH hosts; the cache key must name the
|
||||
// resolved host, which `connectionId` could not tell apart from "unresolved".
|
||||
const cacheKey = `${target.executionHostId}:${worktree.id}:${worktree.path}`
|
||||
const inventory = await this.mobileFilePathSearchCache.get(cacheKey, async () => {
|
||||
const listed = connectionId
|
||||
? await this.listRemoteMobileFiles(
|
||||
worktree.path,
|
||||
connectionId,
|
||||
MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT + 1
|
||||
)
|
||||
: await listQuickOpenFiles(
|
||||
worktree.path,
|
||||
store,
|
||||
undefined,
|
||||
undefined,
|
||||
MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT + 1
|
||||
)
|
||||
const listed =
|
||||
route.kind === 'ssh'
|
||||
? await this.listRemoteMobileFiles(
|
||||
worktree.path,
|
||||
route.provider,
|
||||
MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT + 1
|
||||
)
|
||||
: await listQuickOpenFiles(
|
||||
worktree.path,
|
||||
store,
|
||||
undefined,
|
||||
undefined,
|
||||
MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT + 1
|
||||
)
|
||||
const safePaths = listed
|
||||
.filter((relativePath) => isSafeMobileRelativePath(relativePath))
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
@@ -113,14 +120,15 @@ export class RuntimeFileCommandsWithConstructor extends RuntimeFileCommandsWithA
|
||||
signal?: AbortSignal
|
||||
): Promise<RuntimeFileListResult> {
|
||||
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
|
||||
const { worktree, connectionId } = target
|
||||
const { worktree } = target
|
||||
const route = runtimeFileRouteForTarget(target)
|
||||
const result =
|
||||
!query.trim() || isQuickOpenQueryTooLarge(query)
|
||||
? { paths: [], totalCount: 0, truncated: false }
|
||||
: connectionId
|
||||
: route.kind === 'ssh'
|
||||
? await this.searchRemoteQuickOpenFilePaths(
|
||||
worktree.path,
|
||||
connectionId,
|
||||
route.provider,
|
||||
query,
|
||||
limit,
|
||||
excludePaths,
|
||||
@@ -149,7 +157,8 @@ export class RuntimeFileCommandsWithConstructor extends RuntimeFileCommandsWithA
|
||||
worktreeSelector: string,
|
||||
relativePath: string
|
||||
): Promise<RuntimeFileOpenResult> {
|
||||
const { worktree, connectionId } = await this.host.resolveRuntimeFileTarget(worktreeSelector)
|
||||
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
|
||||
const { worktree } = target
|
||||
if (!isSafeMobileRelativePath(relativePath)) {
|
||||
throw new Error('invalid_relative_path')
|
||||
}
|
||||
@@ -166,7 +175,7 @@ export class RuntimeFileCommandsWithConstructor extends RuntimeFileCommandsWithA
|
||||
}
|
||||
const filePath = joinWorktreeRelativePath(worktree.path, relativePath)
|
||||
// Why: CLI/agents treat opened:true as success; stat first so missing paths fail the RPC instead of opening a ghost tab.
|
||||
await this.assertMobileOpenTargetExists(filePath, connectionId)
|
||||
await this.assertMobileOpenTargetExists(filePath, runtimeFileRouteForTarget(target))
|
||||
// Why: the internal runtimeId isn't a valid env selector; pass undefined so openFile falls back to activeRuntimeEnvironmentId.
|
||||
this.host.openFile(worktree.id, filePath, relativePath, undefined)
|
||||
return { worktree: worktree.id, relativePath, kind, opened: true }
|
||||
@@ -174,16 +183,16 @@ export class RuntimeFileCommandsWithConstructor extends RuntimeFileCommandsWithA
|
||||
|
||||
protected async assertMobileOpenTargetExists(
|
||||
filePath: string,
|
||||
connectionId?: string
|
||||
route: RuntimeFileRoute
|
||||
): Promise<void> {
|
||||
try {
|
||||
await (connectionId
|
||||
? this.statRemoteTerminalPath(filePath, connectionId)
|
||||
await (route.kind === 'ssh'
|
||||
? this.statRemoteTerminalPath(filePath, route.connectionId)
|
||||
: stat(await resolveAuthorizedPath(filePath, this.host.requireStore())))
|
||||
} catch (error) {
|
||||
if (
|
||||
isENOENT(error) ||
|
||||
(connectionId && RuntimeFileCommands.isRemoteNotFoundErrorMessage(error))
|
||||
(route.kind === 'ssh' && RuntimeFileCommands.isRemoteNotFoundErrorMessage(error))
|
||||
) {
|
||||
throw new Error(`ENOENT: no such file or directory, open '${filePath}'`)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
// @ts-nocheck -- mechanically split class members.
|
||||
import { RuntimeFileCommandsWithWriteFileExplorerFile } from './runtime-file-commands-write-file-explorer-file'
|
||||
import { assertRuntimeFileMutationExpectation } from './runtime-file-commands-mobile-file-list-limit'
|
||||
import {
|
||||
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE,
|
||||
getSshFilesystemProvider
|
||||
} from '../providers/ssh-filesystem-dispatch'
|
||||
import { requireRuntimeFileProvider } from './runtime-file-command-target'
|
||||
import { resolveAuthorizedPath } from '../ipc/filesystem-auth'
|
||||
import { constants, copyFile, mkdir, rm } from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
@@ -20,16 +17,13 @@ export class RuntimeFileCommandsWithCreateFileExplorerDirNoClobber extends Runti
|
||||
): Promise<{ ok: true }> {
|
||||
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
||||
assertRuntimeFileMutationExpectation(
|
||||
target.connectionId,
|
||||
target.executionHostId,
|
||||
expectedExecutionHostId,
|
||||
expectedSshTargetId,
|
||||
expectedSshConnectionGeneration
|
||||
)
|
||||
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
const provider = requireRuntimeFileProvider(target)
|
||||
if (provider) {
|
||||
await provider.createDirNoClobber(target.path)
|
||||
return { ok: true }
|
||||
}
|
||||
@@ -52,18 +46,13 @@ export class RuntimeFileCommandsWithCreateFileExplorerDirNoClobber extends Runti
|
||||
finalRelativePath
|
||||
])
|
||||
assertRuntimeFileMutationExpectation(
|
||||
tempTarget.connectionId,
|
||||
tempTarget.executionHostId,
|
||||
expectedExecutionHostId,
|
||||
expectedSshTargetId,
|
||||
expectedSshConnectionGeneration
|
||||
)
|
||||
const provider = tempTarget.connectionId
|
||||
? getSshFilesystemProvider(tempTarget.connectionId)
|
||||
: null
|
||||
if (tempTarget.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
const provider = requireRuntimeFileProvider(tempTarget)
|
||||
if (provider) {
|
||||
await provider.copy(tempTarget.path, finalTarget.path)
|
||||
await provider.deletePath(tempTarget.path, false).catch(() => {})
|
||||
return { ok: true }
|
||||
@@ -91,18 +80,13 @@ export class RuntimeFileCommandsWithCreateFileExplorerDirNoClobber extends Runti
|
||||
newRelativePath
|
||||
])
|
||||
assertRuntimeFileMutationExpectation(
|
||||
oldTarget.connectionId,
|
||||
oldTarget.executionHostId,
|
||||
expectedExecutionHostId,
|
||||
expectedSshTargetId,
|
||||
expectedSshConnectionGeneration
|
||||
)
|
||||
const provider = oldTarget.connectionId
|
||||
? getSshFilesystemProvider(oldTarget.connectionId)
|
||||
: null
|
||||
if (oldTarget.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
const provider = requireRuntimeFileProvider(oldTarget)
|
||||
if (provider) {
|
||||
await provider.renameNoClobber(oldTarget.path, newTarget.path)
|
||||
return { ok: true }
|
||||
}
|
||||
@@ -127,18 +111,13 @@ export class RuntimeFileCommandsWithCreateFileExplorerDirNoClobber extends Runti
|
||||
[sourceRelativePath, destinationRelativePath]
|
||||
)
|
||||
assertRuntimeFileMutationExpectation(
|
||||
sourceTarget.connectionId,
|
||||
sourceTarget.executionHostId,
|
||||
expectedExecutionHostId,
|
||||
expectedSshTargetId,
|
||||
expectedSshConnectionGeneration
|
||||
)
|
||||
const provider = sourceTarget.connectionId
|
||||
? getSshFilesystemProvider(sourceTarget.connectionId)
|
||||
: null
|
||||
if (sourceTarget.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
const provider = requireRuntimeFileProvider(sourceTarget)
|
||||
if (provider) {
|
||||
await provider.copy(sourceTarget.path, destinationTarget.path)
|
||||
return { ok: true }
|
||||
}
|
||||
@@ -166,16 +145,13 @@ export class RuntimeFileCommandsWithCreateFileExplorerDirNoClobber extends Runti
|
||||
): Promise<{ ok: true }> {
|
||||
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
||||
assertRuntimeFileMutationExpectation(
|
||||
target.connectionId,
|
||||
target.executionHostId,
|
||||
expectedExecutionHostId,
|
||||
expectedSshTargetId,
|
||||
expectedSshConnectionGeneration
|
||||
)
|
||||
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
const provider = requireRuntimeFileProvider(target)
|
||||
if (provider) {
|
||||
await provider.deletePath(target.path, recursive)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
remoteRpcResultExceedsContentBudget
|
||||
} from '../../shared/remote-rpc-content-budget'
|
||||
import { constants } from 'node:fs/promises'
|
||||
import { toSshExecutionHostId } from '../../shared/execution-host'
|
||||
import { getSshTargetIdForExecutionHost, type ExecutionHostId } from '../../shared/execution-host'
|
||||
import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation'
|
||||
import { basenameFromRelativePath } from './runtime-file-paths'
|
||||
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
|
||||
@@ -87,7 +87,10 @@ export const RUNTIME_FILE_MUTATION_UPDATE_REQUIRED =
|
||||
'Remote file changes require a newer Orca client. Update the paired client and try again.'
|
||||
|
||||
export function assertRuntimeFileMutationExpectation(
|
||||
connectionId: string | undefined,
|
||||
// The resolved host, not a repo row's connection: recomputing it from `connectionId` here spelled
|
||||
// `runtime:<env>` and "unresolved" as `local`, so a client's host expectation could pass against
|
||||
// a host it never named (#11163).
|
||||
executionHostId: ExecutionHostId,
|
||||
expectedExecutionHostId: string | undefined,
|
||||
expectedSshTargetId: string | undefined,
|
||||
expectedSshConnectionGeneration: number | undefined
|
||||
@@ -95,11 +98,14 @@ export function assertRuntimeFileMutationExpectation(
|
||||
if (!expectedExecutionHostId) {
|
||||
throw new Error(RUNTIME_FILE_MUTATION_UPDATE_REQUIRED)
|
||||
}
|
||||
const actualExecutionHostId = connectionId ? toSshExecutionHostId(connectionId) : 'local'
|
||||
if (expectedExecutionHostId !== actualExecutionHostId) {
|
||||
if (expectedExecutionHostId !== executionHostId) {
|
||||
throw new Error('Workspace host changed; refresh and try again')
|
||||
}
|
||||
assertSshMutationExpectation(connectionId, expectedSshTargetId, expectedSshConnectionGeneration)
|
||||
assertSshMutationExpectation(
|
||||
getSshTargetIdForExecutionHost(executionHostId) ?? undefined,
|
||||
expectedSshTargetId,
|
||||
expectedSshConnectionGeneration
|
||||
)
|
||||
}
|
||||
|
||||
export const pendingRuntimeFileWatcherUnsubscribes = new Set<Promise<void>>()
|
||||
|
||||
@@ -12,10 +12,7 @@ import {
|
||||
previewableBinaryByteLimit,
|
||||
readPreviewFileWithinCap
|
||||
} from './runtime-file-commands-mobile-file-list-limit'
|
||||
import {
|
||||
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE,
|
||||
getSshFilesystemProvider
|
||||
} from '../providers/ssh-filesystem-dispatch'
|
||||
import { requireRuntimeFileProvider } from './runtime-file-command-target'
|
||||
import { open, stat } from 'node:fs/promises'
|
||||
import { resolveAuthorizedPath } from '../ipc/filesystem-auth'
|
||||
import { extname } from 'node:path'
|
||||
@@ -42,11 +39,8 @@ export class RuntimeFileCommandsWithReadFileExplorerPreview extends RuntimeFileC
|
||||
? LOCAL_PREVIEWABLE_BINARY_MAX_BYTES
|
||||
: previewableBinaryByteLimit(maxContentBytes)
|
||||
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
||||
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
const provider = requireRuntimeFileProvider(target)
|
||||
if (provider) {
|
||||
const fileStats = await provider.stat(target.path)
|
||||
if (fileStats.size > binaryMaxBytes) {
|
||||
throw new Error('file_too_large')
|
||||
@@ -140,11 +134,8 @@ export class RuntimeFileCommandsWithReadFileExplorerPreview extends RuntimeFileC
|
||||
maxTextBytes: MOBILE_FILE_READ_MAX_BYTES,
|
||||
maxBinaryBytes: binaryMaxBytes
|
||||
}
|
||||
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
||||
if (target.connectionId && !provider) {
|
||||
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
if (target.connectionId && !provider?.readDocPreviewFile) {
|
||||
const provider = requireRuntimeFileProvider(target)
|
||||
if (provider && !provider.readDocPreviewFile) {
|
||||
throw new Error('Secure document previews require a newer SSH relay')
|
||||
}
|
||||
const result = provider?.readDocPreviewFile
|
||||
@@ -160,11 +151,8 @@ export class RuntimeFileCommandsWithReadFileExplorerPreview extends RuntimeFileC
|
||||
length: number
|
||||
): Promise<RuntimeFileReadChunkResult> {
|
||||
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
||||
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
const provider = requireRuntimeFileProvider(target)
|
||||
if (provider) {
|
||||
const fileStat = await provider.stat(target.path)
|
||||
if (fileStat.type === 'directory') {
|
||||
throw new Error('Cannot download a directory')
|
||||
|
||||
@@ -5,6 +5,7 @@ import { isMobileBinaryPath, isSafeMobileRelativePath } from './runtime-file-com
|
||||
import { joinWorktreeRelativePath } from './runtime-relative-paths'
|
||||
import { readLocalMobileFile } from './runtime-file-commands-terminal-file-paths'
|
||||
import { truncateMobileFilePreview } from './runtime-file-commands-terminal-artifact-access'
|
||||
import { requireRuntimeFileProvider } from './runtime-file-command-target'
|
||||
|
||||
export class RuntimeFileCommandsWithReadMobileFile extends RuntimeFileCommandsWithConstructor {
|
||||
async readMobileFile(
|
||||
@@ -13,7 +14,8 @@ export class RuntimeFileCommandsWithReadMobileFile extends RuntimeFileCommandsWi
|
||||
): Promise<RuntimeFileReadResult> {
|
||||
const store = this.host.requireStore()
|
||||
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
|
||||
const { worktree, connectionId } = target
|
||||
const { worktree } = target
|
||||
const provider = requireRuntimeFileProvider(target)
|
||||
if (!isSafeMobileRelativePath(relativePath)) {
|
||||
throw new Error('invalid_relative_path')
|
||||
}
|
||||
@@ -22,8 +24,8 @@ export class RuntimeFileCommandsWithReadMobileFile extends RuntimeFileCommandsWi
|
||||
}
|
||||
|
||||
const filePath = joinWorktreeRelativePath(worktree.path, relativePath)
|
||||
const content = connectionId
|
||||
? await this.readRemoteMobileFile(filePath, connectionId)
|
||||
const content = provider
|
||||
? await this.readRemoteMobileFile(filePath, provider)
|
||||
: await readLocalMobileFile(filePath, store)
|
||||
const truncated = truncateMobileFilePreview(content)
|
||||
|
||||
|
||||
@@ -23,7 +23,10 @@ import { TERMINAL_FILE_GRANT_TTL_MS } from './runtime-file-commands-mobile-file-
|
||||
import type { RuntimeTerminalPathResolution } from '../../shared/runtime-types'
|
||||
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { ResolvedRuntimeFileTarget } from './runtime-file-watcher-leases'
|
||||
import {
|
||||
runtimeFileSshTargetId,
|
||||
type ResolvedRuntimeFileTarget
|
||||
} from './runtime-file-command-target'
|
||||
|
||||
export class RuntimeFileCommandsWithResolveAllowedTerminalArtifactPath extends RuntimeFileCommandsWithResolveTerminalPath {
|
||||
protected async resolveAllowedTerminalArtifactPath(args: {
|
||||
@@ -188,7 +191,7 @@ export class RuntimeFileCommandsWithResolveAllowedTerminalArtifactPath extends R
|
||||
if (
|
||||
grant.worktreeId !== target.worktree.id ||
|
||||
grant.absolutePath !== absolutePath ||
|
||||
grant.connectionId !== target.connectionId ||
|
||||
grant.connectionId !== runtimeFileSshTargetId(target) ||
|
||||
grant.clientId !== clientId
|
||||
) {
|
||||
throw new Error('terminal_file_grant_mismatch')
|
||||
|
||||
@@ -13,15 +13,12 @@ import {
|
||||
resolveTerminalAbsolutePath
|
||||
} from './runtime-file-commands-terminal-file-paths'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { getRuntimeFileTargetExecutionHostId } from './runtime-file-watcher-leases'
|
||||
import { runtimeFileRouteForTarget } from './runtime-file-command-target'
|
||||
import { isSafeMobileRelativePath } from './runtime-file-command-host'
|
||||
import { resolveAuthorizedPath } from '../ipc/filesystem-auth'
|
||||
import { isENOENT } from '../ipc/filesystem-path-containment'
|
||||
import type { RuntimeFileStatLike } from './runtime-file-commands-mobile-file-list-limit'
|
||||
import {
|
||||
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE,
|
||||
getSshFilesystemProvider
|
||||
} from '../providers/ssh-filesystem-dispatch'
|
||||
import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
|
||||
export class RuntimeFileCommandsWithResolveTerminalPath extends RuntimeFileCommandsWithReadMobileFile {
|
||||
// Resolves a mobile terminal tap to a worktree-relative path; relatives resolve against cwd, else the worktree root.
|
||||
@@ -36,7 +33,9 @@ export class RuntimeFileCommandsWithResolveTerminalPath extends RuntimeFileComma
|
||||
): Promise<RuntimeTerminalPathResolution> {
|
||||
const store = this.host.requireStore()
|
||||
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
|
||||
const { worktree, connectionId } = target
|
||||
const { worktree } = target
|
||||
const route = runtimeFileRouteForTarget(target)
|
||||
const connectionId = route.kind === 'ssh' ? route.connectionId : undefined
|
||||
// Why: mobile may attach after OSC7 cwd was emitted; the runtime still owns the terminal's latest cwd to resolve the tap.
|
||||
const normalizedTerminalHandle =
|
||||
terminalHandle && terminalHandle.trim().length > 0 ? terminalHandle.trim() : null
|
||||
@@ -74,13 +73,13 @@ export class RuntimeFileCommandsWithResolveTerminalPath extends RuntimeFileComma
|
||||
// follow-up files.open, so retargeting to a sibling workspace must be opt-in.
|
||||
const knownWorkspaceTarget =
|
||||
crossWorkspace && relativePath === null
|
||||
? await this.host.resolveKnownWorkspaceFileTarget?.(
|
||||
absolutePath,
|
||||
getRuntimeFileTargetExecutionHostId(target)
|
||||
)
|
||||
? await this.host.resolveKnownWorkspaceFileTarget?.(absolutePath, target.executionHostId)
|
||||
: null
|
||||
const ownedWorktree = knownWorkspaceTarget?.worktree ?? worktree
|
||||
const ownedConnectionId = knownWorkspaceTarget?.connectionId ?? connectionId
|
||||
// Why: the owner's host replaces this target's outright. Coalescing an optional connection
|
||||
// instead let a sibling workspace resolved as `local` inherit this worktree's SSH target and
|
||||
// stat a local path on the remote host.
|
||||
const ownedRoute = runtimeFileRouteForTarget(knownWorkspaceTarget ?? target)
|
||||
const ownedRelativePath = knownWorkspaceTarget?.relativePath ?? relativePath
|
||||
|
||||
try {
|
||||
@@ -88,9 +87,10 @@ export class RuntimeFileCommandsWithResolveTerminalPath extends RuntimeFileComma
|
||||
ownedRelativePath !== null &&
|
||||
(ownedRelativePath === '' || isSafeMobileRelativePath(ownedRelativePath))
|
||||
) {
|
||||
const stats = ownedConnectionId
|
||||
? await this.statRemoteTerminalPath(absolutePath, ownedConnectionId)
|
||||
: await stat(await resolveAuthorizedPath(absolutePath, store))
|
||||
const stats =
|
||||
ownedRoute.kind === 'ssh'
|
||||
? await this.statRemoteTerminalPath(absolutePath, ownedRoute.connectionId)
|
||||
: await stat(await resolveAuthorizedPath(absolutePath, store))
|
||||
return {
|
||||
worktree: ownedWorktree.id,
|
||||
relativePath: ownedRelativePath,
|
||||
@@ -101,7 +101,7 @@ export class RuntimeFileCommandsWithResolveTerminalPath extends RuntimeFileComma
|
||||
? undefined
|
||||
: {
|
||||
kind: 'worktree-file',
|
||||
provider: ownedConnectionId ? 'ssh' : 'local',
|
||||
provider: ownedRoute.kind,
|
||||
relativePath: ownedRelativePath,
|
||||
absolutePath
|
||||
}
|
||||
@@ -168,7 +168,7 @@ export class RuntimeFileCommandsWithResolveTerminalPath extends RuntimeFileComma
|
||||
// Report genuine not-found as missing; let transport/permission errors surface so remote taps aren't all reported missing.
|
||||
if (
|
||||
isENOENT(error) ||
|
||||
(ownedConnectionId && RuntimeFileCommands.isRemoteNotFoundErrorMessage(error))
|
||||
(ownedRoute.kind === 'ssh' && RuntimeFileCommands.isRemoteNotFoundErrorMessage(error))
|
||||
) {
|
||||
return {
|
||||
...empty,
|
||||
@@ -181,15 +181,13 @@ export class RuntimeFileCommandsWithResolveTerminalPath extends RuntimeFileComma
|
||||
}
|
||||
}
|
||||
|
||||
// Leaf helper: only ever reached from a route already resolved to `ssh`, so the id it takes is
|
||||
// this client's dialable target rather than a repo row's raw `connectionId`.
|
||||
protected async statRemoteTerminalPath(
|
||||
absolutePath: string,
|
||||
connectionId: string
|
||||
): Promise<RuntimeFileStatLike & { isDirectory: () => boolean }> {
|
||||
const provider = getSshFilesystemProvider(connectionId)
|
||||
if (!provider) {
|
||||
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
const stats = await provider.stat(absolutePath)
|
||||
const stats = await requireSshFilesystemProvider(connectionId).stat(absolutePath)
|
||||
return { ...stats, isDirectory: () => stats.type === 'directory' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,9 +21,9 @@ import {
|
||||
} from '../../shared/ripgrep-process-availability'
|
||||
import type { ChildProcessHandle } from '../../shared/child-process/process-spec'
|
||||
import { wslAwareSpawn } from '../git/runner'
|
||||
import type { ResolvedRuntimeFileWorktree } from './runtime-file-watcher-leases'
|
||||
import type { RuntimeFileExplorerPath } from './runtime-file-command-target'
|
||||
import type { IFilesystemProvider } from '../providers/types'
|
||||
import { joinWorktreeRelativePath, normalizeRuntimeRelativePath } from './runtime-relative-paths'
|
||||
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
|
||||
export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileCommandsWithSearchRuntimeFiles {
|
||||
protected async searchLocalRuntimeFiles(
|
||||
@@ -176,7 +176,7 @@ export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileC
|
||||
protected async resolveFileExplorerPath(
|
||||
worktreeSelector: string,
|
||||
relativePath: string
|
||||
): Promise<{ worktree: ResolvedRuntimeFileWorktree; path: string; connectionId?: string }> {
|
||||
): Promise<RuntimeFileExplorerPath> {
|
||||
const [target] = await this.resolveFileExplorerPaths(worktreeSelector, [relativePath])
|
||||
return target
|
||||
}
|
||||
@@ -184,7 +184,7 @@ export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileC
|
||||
protected async resolveFileExplorerPaths(
|
||||
worktreeSelector: string,
|
||||
relativePaths: readonly string[]
|
||||
): Promise<{ worktree: ResolvedRuntimeFileWorktree; path: string; connectionId?: string }[]> {
|
||||
): Promise<RuntimeFileExplorerPath[]> {
|
||||
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
|
||||
return relativePaths.map((relativePath) => ({
|
||||
worktree: target.worktree,
|
||||
@@ -192,17 +192,17 @@ export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileC
|
||||
target.worktree.path,
|
||||
normalizeRuntimeRelativePath(relativePath)
|
||||
),
|
||||
connectionId: target.connectionId
|
||||
executionHostId: target.executionHostId
|
||||
}))
|
||||
}
|
||||
|
||||
// `null` provider is the caller's "this host is unreachable" answer, not "list it here".
|
||||
protected async listRemoteMobileFiles(
|
||||
rootPath: string,
|
||||
connectionId: string,
|
||||
provider: IFilesystemProvider | null,
|
||||
maxResults?: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<string[]> {
|
||||
const provider = getSshFilesystemProvider(connectionId)
|
||||
if (!provider) {
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
// @ts-nocheck -- mechanically split class members.
|
||||
import { RuntimeFileCommandsWithSearchLocalRuntimeFiles } from './runtime-file-commands-search-local-runtime-files'
|
||||
import {
|
||||
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE,
|
||||
getSshFilesystemProvider
|
||||
} from '../providers/ssh-filesystem-dispatch'
|
||||
import type { IFilesystemProvider } from '../providers/types'
|
||||
import {
|
||||
MOBILE_FILE_READ_MAX_BYTES,
|
||||
QUICK_OPEN_LEGACY_REMOTE_RESULT_LIMIT
|
||||
@@ -13,13 +10,14 @@ import { QuickOpenPathRanker } from '../../shared/quick-open-path-search'
|
||||
export class RuntimeFileCommandsWithSearchRemoteQuickOpenFilePaths extends RuntimeFileCommandsWithSearchLocalRuntimeFiles {
|
||||
protected async searchRemoteQuickOpenFilePaths(
|
||||
rootPath: string,
|
||||
connectionId: string,
|
||||
// `null` is "remote and currently unreachable": quick open reports no matches rather than
|
||||
// failing the keystroke, but it never falls back to searching this machine.
|
||||
provider: IFilesystemProvider | null,
|
||||
query: string,
|
||||
limit: number,
|
||||
excludePaths?: string[],
|
||||
signal?: AbortSignal
|
||||
): Promise<{ paths: string[]; totalCount: number; truncated: boolean }> {
|
||||
const provider = getSshFilesystemProvider(connectionId)
|
||||
if (!provider) {
|
||||
return { paths: [], totalCount: 0, truncated: false }
|
||||
}
|
||||
@@ -55,11 +53,10 @@ export class RuntimeFileCommandsWithSearchRemoteQuickOpenFilePaths extends Runti
|
||||
}
|
||||
}
|
||||
|
||||
protected async readRemoteMobileFile(filePath: string, connectionId: string): Promise<string> {
|
||||
const provider = getSshFilesystemProvider(connectionId)
|
||||
if (!provider) {
|
||||
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
protected async readRemoteMobileFile(
|
||||
filePath: string,
|
||||
provider: IFilesystemProvider
|
||||
): Promise<string> {
|
||||
const fileStat = await provider.stat(filePath)
|
||||
// Why: no ranged reads over SSH here, so reject oversized previews instead of streaming a whole file just to trim it.
|
||||
if (fileStat.size > MOBILE_FILE_READ_MAX_BYTES) {
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
import { RuntimeFileCommandsWithCreateFileExplorerDirNoClobber } from './runtime-file-commands-create-file-explorer-dir-no-clobber'
|
||||
import type { SearchOptions, SearchResult } from '../../shared/code-search-types'
|
||||
import {
|
||||
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE,
|
||||
getSshFilesystemProvider
|
||||
} from '../providers/ssh-filesystem-dispatch'
|
||||
requireRuntimeFileProvider,
|
||||
runtimeFileRouteForTarget
|
||||
} from './runtime-file-command-target'
|
||||
import { QUICK_OPEN_LISTING_MAX_RESULTS } from '../../shared/quick-open-listing-limits'
|
||||
import { limitQuickOpenFilesBySerializedBytes } from '../../shared/quick-open-transport-budget'
|
||||
import { listQuickOpenFiles } from '../ipc/filesystem-list-files'
|
||||
@@ -22,13 +22,10 @@ export class RuntimeFileCommandsWithSearchRuntimeFiles extends RuntimeFileComman
|
||||
options: Omit<SearchOptions, 'rootPath'>
|
||||
): Promise<SearchResult> {
|
||||
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
|
||||
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
||||
const provider = requireRuntimeFileProvider(target)
|
||||
const rootPath = target.worktree.path
|
||||
const searchOptions = { ...options, rootPath }
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
if (provider) {
|
||||
return provider.search(searchOptions)
|
||||
}
|
||||
return this.searchLocalRuntimeFiles(rootPath, searchOptions)
|
||||
@@ -44,8 +41,10 @@ export class RuntimeFileCommandsWithSearchRuntimeFiles extends RuntimeFileComman
|
||||
} = {}
|
||||
): Promise<string[]> {
|
||||
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
|
||||
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
const route = runtimeFileRouteForTarget(target)
|
||||
if (route.kind === 'ssh') {
|
||||
// Why: quick-open listings degrade to empty for an unreachable host rather than throwing.
|
||||
const provider = route.provider
|
||||
if (!provider) {
|
||||
return []
|
||||
}
|
||||
@@ -73,11 +72,8 @@ export class RuntimeFileCommandsWithSearchRuntimeFiles extends RuntimeFileComman
|
||||
|
||||
async listRuntimeMarkdownDocuments(worktreeSelector: string): Promise<MarkdownDocument[]> {
|
||||
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
|
||||
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
const provider = requireRuntimeFileProvider(target)
|
||||
if (provider) {
|
||||
const relativePaths = await provider.listFiles(target.worktree.path)
|
||||
return markdownDocumentsFromRelativePaths(target.worktree.path, relativePaths)
|
||||
}
|
||||
@@ -89,11 +85,8 @@ export class RuntimeFileCommandsWithSearchRuntimeFiles extends RuntimeFileComman
|
||||
relativePath: string
|
||||
): Promise<{ size: number; isDirectory: boolean; mtime: number }> {
|
||||
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
||||
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
const provider = requireRuntimeFileProvider(target)
|
||||
if (provider) {
|
||||
const fileStat = await provider.stat(target.path)
|
||||
return {
|
||||
size: fileStat.size,
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
// @ts-nocheck -- mechanically split class members.
|
||||
import { RuntimeFileCommandsWithReadFileExplorerPreview } from './runtime-file-commands-read-file-explorer-preview'
|
||||
import { assertRuntimeFileMutationExpectation } from './runtime-file-commands-mobile-file-list-limit'
|
||||
import {
|
||||
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE,
|
||||
getSshFilesystemProvider
|
||||
} from '../providers/ssh-filesystem-dispatch'
|
||||
import { requireRuntimeFileProvider } from './runtime-file-command-target'
|
||||
import { lstat, mkdir, writeFile } from 'node:fs/promises'
|
||||
import { resolveAuthorizedPath } from '../ipc/filesystem-auth'
|
||||
import { isENOENT } from '../ipc/filesystem-path-containment'
|
||||
@@ -25,16 +22,13 @@ export class RuntimeFileCommandsWithWriteFileExplorerFile extends RuntimeFileCom
|
||||
): Promise<{ ok: true }> {
|
||||
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
||||
assertRuntimeFileMutationExpectation(
|
||||
target.connectionId,
|
||||
target.executionHostId,
|
||||
expectedExecutionHostId,
|
||||
expectedSshTargetId,
|
||||
expectedSshConnectionGeneration
|
||||
)
|
||||
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
const provider = requireRuntimeFileProvider(target)
|
||||
if (provider) {
|
||||
await provider.writeFile(target.path, content)
|
||||
return { ok: true }
|
||||
}
|
||||
@@ -64,17 +58,14 @@ export class RuntimeFileCommandsWithWriteFileExplorerFile extends RuntimeFileCom
|
||||
): Promise<{ ok: true }> {
|
||||
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
||||
assertRuntimeFileMutationExpectation(
|
||||
target.connectionId,
|
||||
target.executionHostId,
|
||||
expectedExecutionHostId,
|
||||
expectedSshTargetId,
|
||||
expectedSshConnectionGeneration
|
||||
)
|
||||
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
||||
const provider = requireRuntimeFileProvider(target)
|
||||
const content = Buffer.from(contentBase64, 'base64')
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
if (provider) {
|
||||
await provider.writeFileBase64(target.path, contentBase64)
|
||||
return { ok: true }
|
||||
}
|
||||
@@ -96,17 +87,14 @@ export class RuntimeFileCommandsWithWriteFileExplorerFile extends RuntimeFileCom
|
||||
): Promise<{ ok: true }> {
|
||||
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
||||
assertRuntimeFileMutationExpectation(
|
||||
target.connectionId,
|
||||
target.executionHostId,
|
||||
expectedExecutionHostId,
|
||||
expectedSshTargetId,
|
||||
expectedSshConnectionGeneration
|
||||
)
|
||||
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
||||
const provider = requireRuntimeFileProvider(target)
|
||||
const content = Buffer.from(contentBase64, 'base64')
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
if (provider) {
|
||||
await provider.writeFileBase64Chunk(target.path, contentBase64, append)
|
||||
return { ok: true }
|
||||
}
|
||||
@@ -126,16 +114,13 @@ export class RuntimeFileCommandsWithWriteFileExplorerFile extends RuntimeFileCom
|
||||
): Promise<{ ok: true }> {
|
||||
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
||||
assertRuntimeFileMutationExpectation(
|
||||
target.connectionId,
|
||||
target.executionHostId,
|
||||
expectedExecutionHostId,
|
||||
expectedSshTargetId,
|
||||
expectedSshConnectionGeneration
|
||||
)
|
||||
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
const provider = requireRuntimeFileProvider(target)
|
||||
if (provider) {
|
||||
await provider.createFile(target.path)
|
||||
return { ok: true }
|
||||
}
|
||||
@@ -159,16 +144,13 @@ export class RuntimeFileCommandsWithWriteFileExplorerFile extends RuntimeFileCom
|
||||
): Promise<{ ok: true }> {
|
||||
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
||||
assertRuntimeFileMutationExpectation(
|
||||
target.connectionId,
|
||||
target.executionHostId,
|
||||
expectedExecutionHostId,
|
||||
expectedSshTargetId,
|
||||
expectedSshConnectionGeneration
|
||||
)
|
||||
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
const provider = requireRuntimeFileProvider(target)
|
||||
if (provider) {
|
||||
await provider.createDir(target.path)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Guard the removal of `ResolvedRuntimeFileTarget.connectionId` at the tree level.
|
||||
*
|
||||
* Removing the field is what turned every reader into an error rather than letting old call sites
|
||||
* silently inherit a changed meaning — the way this defect spread. But the whole
|
||||
* `runtime-file-commands-*` family carries `// @ts-nocheck` from a mechanical class split, so the
|
||||
* compiler reports nothing there: a re-introduced `target.connectionId` would read `undefined`,
|
||||
* which is exactly the "unresolved means local" spelling the migration deleted (#11163).
|
||||
*
|
||||
* This test is the compile error those files cannot produce. Routing goes through
|
||||
* `runtime-file-command-target.ts`, which is deliberately not `@ts-nocheck`.
|
||||
*/
|
||||
const RUNTIME_DIR = __dirname
|
||||
const TARGET_MODULE = 'runtime-file-command-target.ts'
|
||||
|
||||
// Matches `target.connectionId`, `tempTarget.connectionId`, `knownWorkspaceTarget?.connectionId`.
|
||||
// Not `grant.connectionId` or `args.connectionId`: a grant and a leaf argument legitimately carry
|
||||
// an SSH target id, having already been resolved from a host.
|
||||
const TARGET_CONNECTION_READ = /\b\w*[Tt]arget\??\.connectionId\b/
|
||||
|
||||
function familyFiles(): string[] {
|
||||
return readdirSync(RUNTIME_DIR).filter(
|
||||
(name) =>
|
||||
(name.startsWith('runtime-file-') || name === 'orca-runtime-file-commands.ts') &&
|
||||
name.endsWith('.ts') &&
|
||||
!name.endsWith('.test.ts')
|
||||
)
|
||||
}
|
||||
|
||||
describe('runtime file target connection field', () => {
|
||||
it('is read nowhere in the runtime file command family', () => {
|
||||
const offenders = familyFiles().filter((name) =>
|
||||
TARGET_CONNECTION_READ.test(readFileSync(join(RUNTIME_DIR, name), 'utf8'))
|
||||
)
|
||||
|
||||
expect(offenders).toEqual([])
|
||||
})
|
||||
|
||||
// The one module in the family the compiler still checks; it is where the routing rule lives.
|
||||
it('routes through a module the compiler still checks', () => {
|
||||
const source = readFileSync(join(RUNTIME_DIR, TARGET_MODULE), 'utf8')
|
||||
|
||||
expect(source).not.toMatch(/@ts-nocheck/)
|
||||
expect(source).toMatch(/executionHostId: ExecutionHostId/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,245 @@
|
||||
// `resolveRuntimeFileTarget` read `store.getRepo(worktree.repoId)?.connectionId` and never looked
|
||||
// at `worktree.hostId`, so one arbitrarily chosen row decided the execution host for ~30 downstream
|
||||
// filesystem dispatches. `undefined` there meant "runtime host", "unresolved" and "genuinely local"
|
||||
// at once (#11163). These cases pin all four answers end to end, through the real SSH filesystem
|
||||
// provider table. Companion to runtime-git-target-execution-host.test.ts.
|
||||
import { afterEach, 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 }
|
||||
}))
|
||||
|
||||
import type * as MarkdownDocumentsModule from '../ipc/markdown-documents'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ listMarkdownDocuments: vi.fn() }))
|
||||
|
||||
vi.mock('../ipc/markdown-documents', async () => ({
|
||||
...(await vi.importActual<typeof MarkdownDocumentsModule>('../ipc/markdown-documents')),
|
||||
listMarkdownDocuments: mocks.listMarkdownDocuments
|
||||
}))
|
||||
|
||||
import { ExecutionHostNotDispatchableError } from '../providers/execution-host-provider-dispatch'
|
||||
import {
|
||||
registerSshFilesystemProvider,
|
||||
unregisterSshFilesystemProvider
|
||||
} from '../providers/ssh-filesystem-dispatch'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
const REMOTE_PATH = '/srv/app-feature'
|
||||
const WORKTREE_ID = 'repo-shared::/srv/app-feature'
|
||||
|
||||
type RuntimeInternals = {
|
||||
resolveWorktreeSelector: (selector: string) => Promise<unknown>
|
||||
}
|
||||
|
||||
function makeRuntime(repos: readonly Record<string, unknown>[], hostId?: string) {
|
||||
const store = {
|
||||
getSettings: () => ({
|
||||
disabledTuiAgents: [],
|
||||
workspaceDir: '/tmp/workspaces'
|
||||
}),
|
||||
getProjectHostSetups: () => [],
|
||||
getProjects: () => [],
|
||||
getFolderWorkspaces: () => [],
|
||||
getRepos: () => repos,
|
||||
getRepo: (id: string) => repos.find((repo) => repo.id === id)
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(store as never)
|
||||
vi.spyOn(runtime as unknown as RuntimeInternals, 'resolveWorktreeSelector').mockResolvedValue({
|
||||
id: WORKTREE_ID,
|
||||
repoId: 'repo-shared',
|
||||
path: REMOTE_PATH,
|
||||
git: {
|
||||
path: REMOTE_PATH,
|
||||
branch: 'main',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
},
|
||||
...(hostId ? { hostId } : {})
|
||||
})
|
||||
return runtime
|
||||
}
|
||||
|
||||
function stubProvider() {
|
||||
return { listFiles: vi.fn().mockResolvedValue(['README.md']) }
|
||||
}
|
||||
|
||||
describe('runtime file target execution host', () => {
|
||||
const registered: string[] = []
|
||||
|
||||
function register(connectionId: string) {
|
||||
const provider = stubProvider()
|
||||
registerSshFilesystemProvider(connectionId, provider as never)
|
||||
registered.push(connectionId)
|
||||
return provider
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
mocks.listMarkdownDocuments.mockReset().mockResolvedValue([])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const connectionId of registered.splice(0)) {
|
||||
unregisterSshFilesystemProvider(connectionId)
|
||||
}
|
||||
})
|
||||
|
||||
// The case whose absence let the original cross-host leak through review: two SSH hosts
|
||||
// registered at once, and the rival row is the one `getRepo` returns first.
|
||||
it('lists an ssh workspace from the host it names, not from a rival row on another ssh host', async () => {
|
||||
const openclaw = register('openclaw')
|
||||
const m4air = register('m4air')
|
||||
const runtime = makeRuntime(
|
||||
[
|
||||
{ id: 'repo-shared', path: '/home/me/app', connectionId: 'openclaw' },
|
||||
{ id: 'repo-shared', path: '/srv/app', connectionId: 'm4air' }
|
||||
],
|
||||
'ssh:m4air'
|
||||
)
|
||||
|
||||
await runtime.listRuntimeMarkdownDocuments(`id:${WORKTREE_ID}`)
|
||||
|
||||
expect(m4air.listFiles).toHaveBeenCalledWith(REMOTE_PATH)
|
||||
expect(openclaw.listFiles).not.toHaveBeenCalled()
|
||||
expect(mocks.listMarkdownDocuments).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("routes to the workspace's host even when the only repo row names a different ssh host", async () => {
|
||||
const openclaw = register('openclaw')
|
||||
const m4air = register('m4air')
|
||||
const runtime = makeRuntime(
|
||||
[{ id: 'repo-shared', path: '/home/me/app', connectionId: 'openclaw' }],
|
||||
'ssh:m4air'
|
||||
)
|
||||
|
||||
await runtime.listRuntimeMarkdownDocuments(`id:${WORKTREE_ID}`)
|
||||
|
||||
expect(m4air.listFiles).toHaveBeenCalledWith(REMOTE_PATH)
|
||||
expect(openclaw.listFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// `local` has no SSH namespace to nest in, so a surviving `connectionId` is a row contradicting
|
||||
// itself. The old shape handed it out and read a local workspace off a remote host.
|
||||
it('ignores a stale connection on a row that declares itself local', async () => {
|
||||
const m4air = register('m4air')
|
||||
const runtime = makeRuntime(
|
||||
[
|
||||
{
|
||||
id: 'repo-shared',
|
||||
path: '/home/me/app',
|
||||
executionHostId: 'local',
|
||||
connectionId: 'm4air'
|
||||
}
|
||||
],
|
||||
'local'
|
||||
)
|
||||
|
||||
await runtime.listRuntimeMarkdownDocuments(`id:${WORKTREE_ID}`)
|
||||
|
||||
expect(m4air.listFiles).not.toHaveBeenCalled()
|
||||
expect(mocks.listMarkdownDocuments).toHaveBeenCalledWith(REMOTE_PATH)
|
||||
})
|
||||
|
||||
// A `runtime:` row's `connectionId` names a target in the *server's* namespace. Reading it here
|
||||
// reaches a same-named target on this client — a silent-wrong-host answer, worse than the
|
||||
// silent-local one it replaced.
|
||||
it('refuses a runtime host whose nested ssh target is also registered on this client', async () => {
|
||||
const impostor = register('nested-1')
|
||||
const runtime = makeRuntime(
|
||||
[
|
||||
{
|
||||
id: 'repo-shared',
|
||||
path: '/srv/app',
|
||||
executionHostId: 'runtime:env-a',
|
||||
connectionId: 'nested-1'
|
||||
}
|
||||
],
|
||||
'runtime:env-a'
|
||||
)
|
||||
|
||||
await expect(runtime.listRuntimeMarkdownDocuments(`id:${WORKTREE_ID}`)).rejects.toThrow(
|
||||
ExecutionHostNotDispatchableError
|
||||
)
|
||||
expect(impostor.listFiles).not.toHaveBeenCalled()
|
||||
expect(mocks.listMarkdownDocuments).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a runtime host with no nested ssh target rather than answering locally', async () => {
|
||||
const runtime = makeRuntime(
|
||||
[
|
||||
{
|
||||
id: 'repo-shared',
|
||||
path: '/srv/app',
|
||||
executionHostId: 'runtime:env-a'
|
||||
}
|
||||
],
|
||||
'runtime:env-a'
|
||||
)
|
||||
|
||||
await expect(runtime.listRuntimeMarkdownDocuments(`id:${WORKTREE_ID}`)).rejects.toThrow(
|
||||
ExecutionHostNotDispatchableError
|
||||
)
|
||||
expect(mocks.listMarkdownDocuments).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses rather than guessing when rival rows disagree and the workspace names no host', async () => {
|
||||
register('m4air')
|
||||
const runtime = makeRuntime([
|
||||
{ id: 'repo-shared', path: '/srv/app', connectionId: 'm4air' },
|
||||
{ id: 'repo-shared', path: '/home/me/app' }
|
||||
])
|
||||
|
||||
await expect(runtime.listRuntimeMarkdownDocuments(`id:${WORKTREE_ID}`)).rejects.toThrow(
|
||||
'worktree_execution_host_unresolved'
|
||||
)
|
||||
expect(mocks.listMarkdownDocuments).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still answers from the single row when the workspace names no host', async () => {
|
||||
const m4air = register('m4air')
|
||||
const runtime = makeRuntime([{ id: 'repo-shared', path: '/srv/app', connectionId: 'm4air' }])
|
||||
|
||||
await runtime.listRuntimeMarkdownDocuments(`id:${WORKTREE_ID}`)
|
||||
|
||||
expect(m4air.listFiles).toHaveBeenCalledWith(REMOTE_PATH)
|
||||
})
|
||||
|
||||
// Losing contact with a remote host is never evidence that its files are here
|
||||
// (docs/reference/ssh-execution-boundary.md).
|
||||
it('reports the dropped connection instead of reading a remote path locally', async () => {
|
||||
const runtime = makeRuntime(
|
||||
[{ id: 'repo-shared', path: '/srv/app', connectionId: 'm4air' }],
|
||||
'ssh:m4air'
|
||||
)
|
||||
|
||||
await expect(runtime.listRuntimeMarkdownDocuments(`id:${WORKTREE_ID}`)).rejects.toThrow(
|
||||
/Remote connection dropped/
|
||||
)
|
||||
expect(mocks.listMarkdownDocuments).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// The mutation guard recomputed the host from `connectionId`, so a client could satisfy a host
|
||||
// expectation the workspace never named.
|
||||
it('rejects a mutation whose expected host is the row rather than the resolved one', async () => {
|
||||
register('m4air')
|
||||
register('openclaw')
|
||||
const runtime = makeRuntime(
|
||||
[{ id: 'repo-shared', path: '/home/me/app', connectionId: 'openclaw' }],
|
||||
'ssh:m4air'
|
||||
)
|
||||
|
||||
await expect(
|
||||
runtime.createFileExplorerDir(
|
||||
`id:${WORKTREE_ID}`,
|
||||
'docs',
|
||||
undefined,
|
||||
undefined,
|
||||
'ssh:openclaw'
|
||||
)
|
||||
).rejects.toThrow('Workspace host changed; refresh and try again')
|
||||
})
|
||||
})
|
||||
@@ -9,9 +9,6 @@ import {
|
||||
} from './runtime-file-commands-mobile-file-list-limit'
|
||||
import { isWatcherProcessFailure } from '../ipc/parcel-watcher-process-failure'
|
||||
import { stopSshFileExplorerWatchRearms } from './runtime-file-commands-ssh-file-watcher-rearm'
|
||||
import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import { toSshExecutionHostId } from '../../shared/execution-host'
|
||||
|
||||
export function registerRuntimeFileWatcherRelease(
|
||||
runtimeId: string,
|
||||
@@ -178,19 +175,3 @@ export function _resetRuntimeFileWatcherLeasesForTests(): void {
|
||||
}
|
||||
runtimeFileWatcherLeasesByOwnerAndRoot.clear()
|
||||
}
|
||||
|
||||
export type ResolvedRuntimeFileWorktree = Worktree & { git: GitWorktreeInfo }
|
||||
|
||||
export type ResolvedRuntimeFileTarget = {
|
||||
worktree: ResolvedRuntimeFileWorktree
|
||||
connectionId?: string
|
||||
}
|
||||
|
||||
export function getRuntimeFileTargetExecutionHostId(
|
||||
target: ResolvedRuntimeFileTarget
|
||||
): ExecutionHostId {
|
||||
return (
|
||||
target.worktree.hostId ??
|
||||
(target.connectionId ? toSshExecutionHostId(target.connectionId) : 'local')
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user