From b031ec9ddede60c60038bbf39d829ddac7fa7eda Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:22:21 -0700 Subject: [PATCH] feat(providers): dispatch git and filesystem providers by execution host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `const c = repo.connectionId; c ? sshProvider(c) : local()` overloads `null` to mean both "resolved: local" and "could not resolve", so every path that cannot determine the host silently runs remote work on the client (#11163). It also cannot express a `runtime:` host at all. Add a host-keyed dispatch whose input is an `ExecutionHostId` — never null — with `local`, `ssh` and `runtime` as three symmetric entries, and which throws on an id that names no host instead of degrading to this machine. `ssh` carries `provider: null` for "remote, currently unreachable", which is now a different answer from "local" rather than the same one. `runtime:` is a distinct entry rather than a provider because main does not execute runtime hosts at all: they are forwarded over the environment transport, and a runtime row's `connectionId` names a target in the server's namespace. Dialing it from this client's SSH table would trade a silent-local bug for a silent-wrong-host one. First migrations, both to rows resolved via `getRepoExecutionHostId`: - repo-worktrees: an `executionHostId: 'ssh:*'`-only row no longer lists, root-matches, or strict-lists against a same-named local path. - workspace-space-repo-scan: same for the size scan, and `isRemote` no longer contradicts the `executionHostId` emitted beside it. --- .../execution-host-provider-dispatch.test.ts | 105 ++++++++++++ .../execution-host-provider-dispatch.ts | 155 ++++++++++++++++++ src/main/repo-worktrees.test.ts | 61 ++++++- src/main/repo-worktrees.ts | 40 +++-- src/main/workspace-space-repo-scan.ts | 88 +++++----- 5 files changed, 400 insertions(+), 49 deletions(-) create mode 100644 src/main/providers/execution-host-provider-dispatch.test.ts create mode 100644 src/main/providers/execution-host-provider-dispatch.ts diff --git a/src/main/providers/execution-host-provider-dispatch.test.ts b/src/main/providers/execution-host-provider-dispatch.test.ts new file mode 100644 index 00000000000..fd68deea787 --- /dev/null +++ b/src/main/providers/execution-host-provider-dispatch.test.ts @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + ExecutionHostNotDispatchableError, + requireFilesystemProviderForHost, + requireGitProviderForHost, + resolveFilesystemRouteForHost, + resolveGitRouteForHost, + UnresolvableExecutionHostError +} from './execution-host-provider-dispatch' +import { registerSshGitProvider, unregisterSshGitProvider } from './ssh-git-dispatch' +import { + registerSshFilesystemProvider, + unregisterSshFilesystemProvider +} from './ssh-filesystem-dispatch' + +const connectionId = 'host-dispatch-target' +const gitProvider = { listWorktrees: async () => [] } as never +const filesystemProvider = { readDir: async () => [] } as never + +describe('execution host provider dispatch', () => { + afterEach(() => { + unregisterSshGitProvider(connectionId) + unregisterSshFilesystemProvider(connectionId) + }) + + it('routes `local` to the local entry rather than to a provider', () => { + expect(resolveGitRouteForHost('local')).toEqual({ kind: 'local', hostId: 'local' }) + expect(resolveFilesystemRouteForHost('local')).toEqual({ kind: 'local', hostId: 'local' }) + }) + + it('routes an ssh host to its registered provider', () => { + registerSshGitProvider(connectionId, gitProvider) + registerSshFilesystemProvider(connectionId, filesystemProvider) + + expect(resolveGitRouteForHost(`ssh:${connectionId}`)).toEqual({ + kind: 'ssh', + hostId: `ssh:${connectionId}`, + connectionId, + provider: gitProvider + }) + expect(resolveFilesystemRouteForHost(`ssh:${connectionId}`)).toEqual({ + kind: 'ssh', + hostId: `ssh:${connectionId}`, + connectionId, + provider: filesystemProvider + }) + expect(requireGitProviderForHost(`ssh:${connectionId}`)).toBe(gitProvider) + expect(requireFilesystemProviderForHost(`ssh:${connectionId}`)).toBe(filesystemProvider) + }) + + it('answers `unreachable`, not `local`, for an ssh host with no registered provider', () => { + const route = resolveGitRouteForHost(`ssh:${connectionId}`) + + // The distinction the old `connectionId ? ssh : local` shape could not spell. + expect(route.kind).toBe('ssh') + expect(route.kind === 'ssh' && route.provider).toBeNull() + expect(() => requireGitProviderForHost(`ssh:${connectionId}`)).toThrow( + /Remote connection dropped/ + ) + expect(() => requireFilesystemProviderForHost(`ssh:${connectionId}`)).toThrow( + /Remote connection dropped/ + ) + }) + + it('routes a runtime host to its own entry instead of collapsing it into local', () => { + expect(resolveGitRouteForHost('runtime:env-7')).toEqual({ + kind: 'runtime', + hostId: 'runtime:env-7', + environmentId: 'env-7' + }) + expect(resolveFilesystemRouteForHost('runtime:env-7')).toEqual({ + kind: 'runtime', + hostId: 'runtime:env-7', + environmentId: 'env-7' + }) + }) + + it('refuses to hand a runtime host to this process’s ssh table', () => { + // A runtime repo row carries the *server's* nested target id. Dialling it here would reach a + // same-named target in this client's namespace. + registerSshGitProvider(connectionId, gitProvider) + + expect(() => requireGitProviderForHost('runtime:env-7')).toThrow( + ExecutionHostNotDispatchableError + ) + expect(() => requireFilesystemProviderForHost('runtime:env-7')).toThrow( + ExecutionHostNotDispatchableError + ) + }) + + it('refuses to serve a local host from the remote-only accessor', () => { + expect(() => requireGitProviderForHost('local')).toThrow(ExecutionHostNotDispatchableError) + expect(() => requireFilesystemProviderForHost('local')).toThrow( + ExecutionHostNotDispatchableError + ) + }) + + it.each([null, undefined, '', 'nonsense', 'ssh:', 'runtime:', 'ssh:a|b'])( + 'throws instead of answering local for the unresolvable host %p', + (hostId) => { + expect(() => resolveGitRouteForHost(hostId)).toThrow(UnresolvableExecutionHostError) + expect(() => resolveFilesystemRouteForHost(hostId)).toThrow(UnresolvableExecutionHostError) + } + ) +}) diff --git a/src/main/providers/execution-host-provider-dispatch.ts b/src/main/providers/execution-host-provider-dispatch.ts new file mode 100644 index 00000000000..1034ceac140 --- /dev/null +++ b/src/main/providers/execution-host-provider-dispatch.ts @@ -0,0 +1,155 @@ +/** + * Host-keyed provider dispatch: one entry per execution host kind, with `local` among them. + * + * The incumbent spelling across main is `const c = repo.connectionId; c ? sshProvider(c) : local()`, + * where `null` means *both* "resolved: this is local" and "could not resolve". Every path that + * cannot determine the host therefore answers "local" and runs remote work on the client — the + * #11163 defect class, which has produced a reproduced cross-host leak (an `ssh:` worktree + * resolving to another target) and near-misses where a transcript that exists only on a remote host + * would have been read locally. The shape also cannot express a `runtime:` host at all. + * + * This module removes that spelling. Its input is an `ExecutionHostId`, which is never null, and an + * id that names no host throws instead of degrading. `getRepoExecutionHostId` / + * `getWorktreeExecutionHostId` / `resolveWorktreeExecutionHost` are the resolution layer that feeds + * it; the last one already answers `unresolved` as a distinct verdict rather than "local". + * + * Why a route union rather than a uniform `getGitProviderForHost(): IGitProvider`, which is the + * VS Code shape (`registerProvider(Schemas.file, …)` symmetric with `Schemas.vscodeRemote`, and + * `ENOPRO` when nothing matches). Two properties of this process, not style preferences: + * + * - `local` git and filesystem work is free functions taking per-worktree execution options + * (`wslDistro`, `sharedLinkPaths`, admission tier), not an `IGitProvider`. There is no local + * provider object to register, and a stateless one would silently drop WSL routing. + * - `runtime:` is not executed in this process *at all*. It is forwarded over the + * environment's transport (`runtimeEnvironments:call`) and the receiving server normalizes it to + * its own `local`. A repo row on a runtime host carries the server's *nested* SSH target in + * `connectionId`; that id is addressable only as the pair (environmentId, targetId). Handing it + * to this client's SSH table would dial a same-named target in the wrong namespace — turning a + * silent-local bug into a silent-wrong-host bug. `host-repo-catalog-snapshot` and + * `host-qualified-worktree-listing` already reject runtime hosts for the same reason. + * + * So the answer is Zed's shape — an enum on the owner (`Local { fs }` vs `Remote { … }`) — and the + * three kinds are symmetric variants of it. Callers switch exhaustively, so `runtime` can no longer + * collapse into `local` by omission. + * + * Note the deliberate second distinction inside the `ssh` variant: `provider: null` means "this host + * is remote and currently unreachable", which is not the same answer as "this host is local" and can + * no longer be spelled the same way. That mirrors the `live` / `unverifiable` / `exited` rule in + * docs/reference/ssh-execution-boundary.md — loss of contact is never evidence of locality. + */ + +import { + parseExecutionHostId, + type ExecutionHostId, + type LOCAL_EXECUTION_HOST_ID, + type ParsedExecutionHost +} from '../../shared/execution-host' +import { getSshGitProvider, SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE } from './ssh-git-dispatch' +import { + getSshFilesystemProvider, + SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE +} from './ssh-filesystem-dispatch' +import type { IFilesystemProvider, IGitProvider } from './types' + +/** An id that names no execution host. Never degrade to local — that is the whole defect class. */ +export class UnresolvableExecutionHostError extends Error { + constructor(readonly hostId: string | null | undefined) { + super( + `Cannot route work: ${JSON.stringify(hostId ?? null)} names no execution host. ` + + 'Refusing to fall back to this machine.' + ) + this.name = 'UnresolvableExecutionHostError' + } +} + +/** Asking this process for a host it does not execute is a routing mistake, not a fallback. */ +export class ExecutionHostNotDispatchableError extends Error { + constructor(readonly hostId: ExecutionHostId) { + super(`Execution host ${hostId} is not dispatched by this process.`) + this.name = 'ExecutionHostNotDispatchableError' + } +} + +type LocalRoute = { kind: 'local'; hostId: typeof LOCAL_EXECUTION_HOST_ID } +type RuntimeRoute = { kind: 'runtime'; hostId: `runtime:${string}`; environmentId: string } +type SshRoute = { + kind: 'ssh' + hostId: `ssh:${string}` + connectionId: string + /** `null` is "remote, currently unreachable" — never "local". */ + provider: TProvider | null +} + +export type ExecutionHostGitRoute = LocalRoute | RuntimeRoute | SshRoute +export type ExecutionHostFilesystemRoute = LocalRoute | RuntimeRoute | SshRoute + +// Takes an unvalidated string rather than `ExecutionHostId`: validating is the point, and host +// ids also arrive from persistence and IPC where the compiler cannot vouch for them. +function parseRoutableHost(hostId: string | null | undefined): ParsedExecutionHost { + const parsed = parseExecutionHostId(hostId) + if (!parsed) { + throw new UnresolvableExecutionHostError(hostId) + } + return parsed +} + +export function resolveGitRouteForHost(hostId: string | null | undefined): ExecutionHostGitRoute { + const parsed = parseRoutableHost(hostId) + switch (parsed.kind) { + case 'local': + return { kind: 'local', hostId: parsed.id } + case 'ssh': + return { + kind: 'ssh', + hostId: parsed.id, + connectionId: parsed.targetId, + provider: getSshGitProvider(parsed.targetId) ?? null + } + case 'runtime': + return { kind: 'runtime', hostId: parsed.id, environmentId: parsed.environmentId } + } +} + +export function resolveFilesystemRouteForHost( + hostId: string | null | undefined +): ExecutionHostFilesystemRoute { + const parsed = parseRoutableHost(hostId) + switch (parsed.kind) { + case 'local': + return { kind: 'local', hostId: parsed.id } + case 'ssh': + return { + kind: 'ssh', + hostId: parsed.id, + connectionId: parsed.targetId, + provider: getSshFilesystemProvider(parsed.targetId) ?? null + } + case 'runtime': + return { kind: 'runtime', hostId: parsed.id, environmentId: parsed.environmentId } + } +} + +/** For call sites that are structurally remote-only: local and runtime are both routing errors. */ +export function requireGitProviderForHost(hostId: string | null | undefined): IGitProvider { + const route = resolveGitRouteForHost(hostId) + if (route.kind !== 'ssh') { + throw new ExecutionHostNotDispatchableError(route.hostId) + } + if (!route.provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return route.provider +} + +export function requireFilesystemProviderForHost( + hostId: string | null | undefined +): IFilesystemProvider { + const route = resolveFilesystemRouteForHost(hostId) + if (route.kind !== 'ssh') { + throw new ExecutionHostNotDispatchableError(route.hostId) + } + if (!route.provider) { + throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) + } + return route.provider +} diff --git a/src/main/repo-worktrees.test.ts b/src/main/repo-worktrees.test.ts index d1c3935c1e1..a6b20c5445f 100644 --- a/src/main/repo-worktrees.test.ts +++ b/src/main/repo-worktrees.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { listWorktreeGraphMock, listWorktreesMock, listWorktreesStrictMock } = vi.hoisted(() => ({ listWorktreeGraphMock: vi.fn(), @@ -19,6 +19,8 @@ import { listRepoWorktreeGraph, listRepoWorktrees } from './repo-worktrees' +import { registerSshGitProvider, unregisterSshGitProvider } from './providers/ssh-git-dispatch' +import { WorktreeCatalogUnavailableError } from '../shared/worktree/worktree-catalog-availability' describe('repo-worktrees', () => { beforeEach(() => { @@ -196,6 +198,63 @@ describe('repo-worktrees', () => { expect(listWorktreesStrictMock).not.toHaveBeenCalled() }) + // #11163: a row may spell its owner only as `executionHostId`. Reading `connectionId` answers + // "local" for it and runs the listing against a same-named path on this machine. + describe('rows that spell their owner only as executionHostId', () => { + const sshOnlyRepo = { + id: 'repo-1', + path: '/srv/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + kind: 'git' as const, + executionHostId: 'ssh:host-a' as const + } + + afterEach(() => { + unregisterSshGitProvider('host-a') + unregisterSshGitProvider('nested-target') + }) + + it('never lists an ssh-owned row with local git', async () => { + await expect(listRepoWorktrees(sshOnlyRepo)).rejects.toThrow(WorktreeCatalogUnavailableError) + expect(listWorktreesMock).not.toHaveBeenCalled() + }) + + it('lists an ssh-owned row through its registered provider', async () => { + const listWorktrees = vi.fn().mockResolvedValue([{ path: '/srv/repo' }]) + registerSshGitProvider('host-a', { listWorktrees } as never) + + await expect(listRepoWorktrees(sshOnlyRepo)).resolves.toEqual([{ path: '/srv/repo' }]) + expect(listWorktrees).toHaveBeenCalledWith('/srv/repo') + expect(listWorktreesMock).not.toHaveBeenCalled() + }) + + it('keeps an ssh-owned root out of the local repo-root match', () => { + expect(isRepoRoot([sshOnlyRepo], '/srv/repo')).toBe(false) + }) + + it('rejects strict local listing for an ssh-owned row', async () => { + await expect(listLocalRepoWorktreesStrict(sshOnlyRepo)).rejects.toThrow('remote repository') + expect(listWorktreesStrictMock).not.toHaveBeenCalled() + }) + + it('refuses to answer a runtime-owned row from a same-named local target', async () => { + const listWorktrees = vi.fn().mockResolvedValue([{ path: '/wrong/host' }]) + registerSshGitProvider('nested-target', { listWorktrees } as never) + + await expect( + listRepoWorktrees({ + ...sshOnlyRepo, + executionHostId: 'runtime:env-7', + connectionId: 'nested-target' + }) + ).rejects.toThrow(WorktreeCatalogUnavailableError) + expect(listWorktrees).not.toHaveBeenCalled() + expect(listWorktreesMock).not.toHaveBeenCalled() + }) + }) + it('treats Windows repo root casing differences as the same local root', () => { const repos = [ { diff --git a/src/main/repo-worktrees.ts b/src/main/repo-worktrees.ts index b7c6e16f91a..228f303bfa2 100644 --- a/src/main/repo-worktrees.ts +++ b/src/main/repo-worktrees.ts @@ -2,7 +2,8 @@ import type { Repo } from '../shared/repo-types' import type { GitWorktreeInfo } from '../shared/worktree/types' import { listWorktreeGraph, listWorktrees, listWorktreesStrict } from './git/worktree' import { isFolderRepo } from '../shared/repo-kind' -import { getSshGitProvider } from './providers/ssh-git-dispatch' +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host' +import { resolveGitRouteForHost } from './providers/execution-host-provider-dispatch' import { areWorktreePathsEqual } from './ipc/worktree-logic' import { WorktreeCatalogUnavailableError } from '../shared/worktree/worktree-catalog-availability' @@ -16,8 +17,12 @@ function hasLocalRepoWorktreeListOptions(options: LocalRepoWorktreeListOptions | } export function isRepoRoot(repos: Repo[], resolvedTarget: string): boolean { + // Why: `!repo.connectionId` matched a remote path against a local one for a row that spells its + // owner only as `executionHostId: 'ssh:'`. Resolve the host instead of reading one field. return repos.some( - (repo) => !repo.connectionId && areWorktreePathsEqual(repo.path, resolvedTarget) + (repo) => + getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID && + areWorktreePathsEqual(repo.path, resolvedTarget) ) } @@ -41,17 +46,24 @@ export async function listRepoWorktrees( if (isFolderRepo(repo)) { return [createFolderWorktree(repo)] } - if (repo.connectionId) { - const provider = getSshGitProvider(repo.connectionId) + const route = resolveGitRouteForHost(getRepoExecutionHostId(repo)) + if (route.kind === 'runtime') { + // A runtime row's `connectionId` names a target in the *server's* namespace, not one this + // client may dial. Reading it here would answer from a same-named local target. + throw new WorktreeCatalogUnavailableError( + `Worktree catalog unavailable for ${repo.path}: host ${route.hostId} is not reachable from this process.` + ) + } + if (route.kind === 'ssh') { // Why: runtime worktree resolution can run before SSH providers have reattached during startup. // Never fall back to local git against a server path, and never report the unreachable host as an // empty catalog (#14004) — callers treat a resolved listing as authoritative. - if (!provider) { + if (!route.provider) { throw new WorktreeCatalogUnavailableError( - `Worktree catalog unavailable for ${repo.path}: SSH connection "${repo.connectionId}" is not connected.` + `Worktree catalog unavailable for ${repo.path}: SSH connection "${route.connectionId}" is not connected.` ) } - return await provider.listWorktrees(repo.path) + return await route.provider.listWorktrees(repo.path) } return hasLocalRepoWorktreeListOptions(options) ? await listWorktrees(repo.path, options) @@ -72,9 +84,15 @@ export async function listRepoWorktreeGraph( if (isFolderRepo(repo)) { return [createFolderWorktree(repo)] } - if (repo.connectionId) { - const provider = getSshGitProvider(repo.connectionId) - return provider ? await provider.listWorktrees(repo.path) : [] + const route = resolveGitRouteForHost(getRepoExecutionHostId(repo)) + // An unreachable remote host answers `[]` here, unlike listRepoWorktrees above, which throws. + // Preserved as-is: this call site's callers treat the graph as best-effort. The inconsistency is + // real but is a separate behavior decision from resolving the host correctly. + if (route.kind === 'runtime') { + return [] + } + if (route.kind === 'ssh') { + return route.provider ? await route.provider.listWorktrees(repo.path) : [] } return hasLocalRepoWorktreeListOptions(options) ? await listWorktreeGraph(repo.path, options) @@ -85,7 +103,7 @@ export async function listLocalRepoWorktreesStrict( repo: Repo, options?: LocalRepoWorktreeListOptions ): Promise { - if (repo.connectionId) { + if (getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID) { throw new Error('Cannot list worktrees for a remote repository') } if (isFolderRepo(repo)) { diff --git a/src/main/workspace-space-repo-scan.ts b/src/main/workspace-space-repo-scan.ts index 9a0bc316403..3bf3c6cf1f4 100644 --- a/src/main/workspace-space-repo-scan.ts +++ b/src/main/workspace-space-repo-scan.ts @@ -9,11 +9,13 @@ import type { WorkspaceSpaceWorktree } from '../shared/workspace-space-types' import { mapWithConcurrency } from '../shared/map-with-concurrency' -import { getRepoExecutionHostId } from '../shared/execution-host' +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host' import { readWorktreeMetaForHost } from './persistence/host-qualified-worktree-meta' import { getRepoOwnedWorktreeMeta } from './worktree-metadata-ownership' -import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch' -import { getSshGitProvider } from './providers/ssh-git-dispatch' +import { + resolveFilesystemRouteForHost, + resolveGitRouteForHost +} from './providers/execution-host-provider-dispatch' import { createFolderWorktree, listRepoWorktrees } from './repo-worktrees' import { mergeWorktree } from './ipc/worktree-logic' import { getLocalProjectWorktreeGitOptions } from './project-runtime-git-options' @@ -89,16 +91,25 @@ async function listWorktreesForSpaceScan( if (isFolderRepo(repo)) { return { ok: true, worktrees: [createFolderWorktree(repo)] } } - if (repo.connectionId) { - const provider = getSshGitProvider(repo.connectionId) - if (!provider) { + // Why: the raw `connectionId` field answers "local" for a row that spells its owner only as + // `executionHostId: 'ssh:'`, which sizes a same-named path on this machine instead. + const route = resolveGitRouteForHost(getRepoExecutionHostId(repo)) + if (route.kind === 'runtime') { + return { + ok: false, + status: 'unavailable', + error: `Host ${route.hostId} is not reachable from this process.` + } + } + if (route.kind === 'ssh') { + if (!route.provider) { return { ok: false, status: 'unavailable', - error: `SSH connection "${repo.connectionId}" is not connected.` + error: `SSH connection "${route.connectionId}" is not connected.` } } - const worktrees = await provider.listWorktrees(repo.path, { signal }) + const worktrees = await route.provider.listWorktrees(repo.path, { signal }) throwIfWorkspaceSpaceScanAborted(signal) return { ok: true, worktrees } } @@ -175,7 +186,7 @@ export async function scanWorkspaceSpaceRepo(args: { executionHostId: getRepoExecutionHostId(repo), displayName: repo.displayName, path: repo.path, - isRemote: Boolean(repo.connectionId), + isRemote: getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID, worktreeCount: 0, scannedWorktreeCount: 0, unavailableWorktreeCount: 1, @@ -193,7 +204,7 @@ export async function scanWorkspaceSpaceRepo(args: { { totalWorktreeCount: progress.totalWorktreeCount + worktrees.length }, options.onProgress ) - const remoteProvider = repo.connectionId ? getSshFilesystemProvider(repo.connectionId) : undefined + const filesystemRoute = resolveFilesystemRouteForHost(getRepoExecutionHostId(repo)) const rows = await mapWithConcurrency(worktrees, WORKTREE_SCAN_CONCURRENCY, async (worktree) => { throwIfWorkspaceSpaceScanAborted(options.signal) reportProgress( @@ -204,33 +215,36 @@ export async function scanWorkspaceSpaceRepo(args: { }, options.onProgress ) - const row = repo.connectionId - ? remoteProvider - ? await scanRemoteWorkspaceSpaceWorktree( - repo, - worktree, - scannedAt, - remoteProvider, - limiters.remoteFallbackTraversal, - options.signal + const row = + filesystemRoute.kind !== 'local' + ? filesystemRoute.kind === 'ssh' && filesystemRoute.provider + ? await scanRemoteWorkspaceSpaceWorktree( + repo, + worktree, + scannedAt, + filesystemRoute.provider, + limiters.remoteFallbackTraversal, + options.signal + ) + : createUnavailableWorkspaceSpaceRow( + repo, + worktree, + scannedAt, + 'unavailable', + filesystemRoute.kind === 'ssh' + ? `SSH filesystem for "${filesystemRoute.connectionId}" is not connected.` + : `Host ${filesystemRoute.hostId} is not reachable from this process.` + ) + : await limiters.localWorktree(() => + scanLocalWorkspaceSpaceWorktree( + repo, + worktree, + scannedAt, + args.readLocalDuDepthOne, + args.normalizeLocalDuPath, + options.signal + ) ) - : createUnavailableWorkspaceSpaceRow( - repo, - worktree, - scannedAt, - 'unavailable', - `SSH filesystem for "${repo.connectionId}" is not connected.` - ) - : await limiters.localWorktree(() => - scanLocalWorkspaceSpaceWorktree( - repo, - worktree, - scannedAt, - args.readLocalDuDepthOne, - args.normalizeLocalDuPath, - options.signal - ) - ) reportProgress( progress, { @@ -265,7 +279,7 @@ export async function scanWorkspaceSpaceRepo(args: { executionHostId: getRepoExecutionHostId(repo), displayName: repo.displayName, path: repo.path, - isRemote: Boolean(repo.connectionId), + isRemote: getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID, worktreeCount: rows.length, ...summary, error: null