mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 16:02:56 +00:00
`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.
223 lines
8.6 KiB
TypeScript
223 lines
8.6 KiB
TypeScript
// @ts-nocheck -- mechanically split class members.
|
|
import {
|
|
RuntimeFileCommandsWithActiveRuntimeTextSearches,
|
|
RuntimeFileCommandsWithActiveRuntimeTextSearches as RuntimeFileCommands
|
|
} from './runtime-file-commands-active-runtime-text-searches'
|
|
import type { RuntimeFileCommandHost } from './runtime-file-command-host'
|
|
import {
|
|
isMobileBinaryPath,
|
|
isMobileMarkdownPath,
|
|
isSafeMobileRelativePath
|
|
} from './runtime-file-command-host'
|
|
import { basenameFromRelativePath } from './runtime-file-paths'
|
|
import type { RuntimeFileListResult, RuntimeFileOpenResult } from '../../shared/runtime-types'
|
|
import { listQuickOpenFiles } from '../ipc/filesystem-list-files'
|
|
import {
|
|
MOBILE_FILE_LIST_LIMIT,
|
|
MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT,
|
|
isMobilePreviewableImagePath
|
|
} from './runtime-file-commands-mobile-file-list-limit'
|
|
import { rankRuntimeMobileFilePaths } from './runtime-mobile-file-path-search'
|
|
import { isQuickOpenQueryTooLarge } from '../../shared/quick-open-path-search'
|
|
import { searchQuickOpenFilePaths as searchHostQuickOpenFilePaths } from '../ipc/filesystem-search-file-paths'
|
|
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) {
|
|
super()
|
|
}
|
|
|
|
async listMobileFiles(
|
|
worktreeSelector: string,
|
|
options: { signal?: AbortSignal } = {}
|
|
): Promise<RuntimeFileListResult> {
|
|
const store = this.host.requireStore()
|
|
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
|
|
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))
|
|
.slice(0, MOBILE_FILE_LIST_LIMIT)
|
|
.map((relativePath) => ({
|
|
relativePath,
|
|
basename: basenameFromRelativePath(relativePath),
|
|
kind: isMobileBinaryPath(relativePath) ? ('binary' as const) : ('text' as const)
|
|
}))
|
|
|
|
return {
|
|
worktree: worktree.id,
|
|
rootPath: worktree.path,
|
|
files: entries,
|
|
totalCount: files.length,
|
|
truncated: files.length > MOBILE_FILE_LIST_LIMIT
|
|
}
|
|
}
|
|
|
|
async searchMobileFilePaths(
|
|
worktreeSelector: string,
|
|
query: string,
|
|
limit: number
|
|
): Promise<RuntimeFileListResult> {
|
|
const store = this.host.requireStore()
|
|
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
|
|
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 =
|
|
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))
|
|
return {
|
|
paths: safePaths.slice(0, MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT),
|
|
totalCount: safePaths.length,
|
|
truncated: safePaths.length > MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT
|
|
}
|
|
})
|
|
const matches = rankRuntimeMobileFilePaths(inventory.paths, query, limit)
|
|
return {
|
|
worktree: worktree.id,
|
|
rootPath: worktree.path,
|
|
files: matches.paths.map((relativePath) => ({
|
|
relativePath,
|
|
basename: basenameFromRelativePath(relativePath),
|
|
kind: isMobileBinaryPath(relativePath) ? ('binary' as const) : ('text' as const)
|
|
})),
|
|
totalCount: matches.totalCount,
|
|
truncated: inventory.truncated || matches.totalCount > limit
|
|
}
|
|
}
|
|
|
|
async searchQuickOpenFilePaths(
|
|
worktreeSelector: string,
|
|
query: string,
|
|
limit: number,
|
|
excludePaths?: string[],
|
|
signal?: AbortSignal
|
|
): Promise<RuntimeFileListResult> {
|
|
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
|
|
const { worktree } = target
|
|
const route = runtimeFileRouteForTarget(target)
|
|
const result =
|
|
!query.trim() || isQuickOpenQueryTooLarge(query)
|
|
? { paths: [], totalCount: 0, truncated: false }
|
|
: route.kind === 'ssh'
|
|
? await this.searchRemoteQuickOpenFilePaths(
|
|
worktree.path,
|
|
route.provider,
|
|
query,
|
|
limit,
|
|
excludePaths,
|
|
signal
|
|
)
|
|
: await searchHostQuickOpenFilePaths(worktree.path, this.host.requireStore(), {
|
|
query,
|
|
limit,
|
|
excludePaths,
|
|
signal
|
|
})
|
|
return {
|
|
worktree: worktree.id,
|
|
rootPath: worktree.path,
|
|
files: result.paths.map((relativePath) => ({
|
|
relativePath,
|
|
basename: basenameFromRelativePath(relativePath),
|
|
kind: isMobileBinaryPath(relativePath) ? ('binary' as const) : ('text' as const)
|
|
})),
|
|
totalCount: result.totalCount,
|
|
truncated: result.truncated
|
|
}
|
|
}
|
|
|
|
async openMobileFile(
|
|
worktreeSelector: string,
|
|
relativePath: string
|
|
): Promise<RuntimeFileOpenResult> {
|
|
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
|
|
const { worktree } = target
|
|
if (!isSafeMobileRelativePath(relativePath)) {
|
|
throw new Error('invalid_relative_path')
|
|
}
|
|
// Previewable images open like text (mobile renders via files.readPreview); other binaries stay unavailable on mobile.
|
|
const kind = isMobilePreviewableImagePath(relativePath)
|
|
? 'image'
|
|
: isMobileBinaryPath(relativePath)
|
|
? 'binary'
|
|
: isMobileMarkdownPath(relativePath)
|
|
? 'markdown'
|
|
: 'text'
|
|
if (kind === 'binary') {
|
|
return { worktree: worktree.id, relativePath, kind, opened: false }
|
|
}
|
|
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, 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 }
|
|
}
|
|
|
|
protected async assertMobileOpenTargetExists(
|
|
filePath: string,
|
|
route: RuntimeFileRoute
|
|
): Promise<void> {
|
|
try {
|
|
await (route.kind === 'ssh'
|
|
? this.statRemoteTerminalPath(filePath, route.connectionId)
|
|
: stat(await resolveAuthorizedPath(filePath, this.host.requireStore())))
|
|
} catch (error) {
|
|
if (
|
|
isENOENT(error) ||
|
|
(route.kind === 'ssh' && RuntimeFileCommands.isRemoteNotFoundErrorMessage(error))
|
|
) {
|
|
throw new Error(`ENOENT: no such file or directory, open '${filePath}'`)
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async openMobileDiff(
|
|
worktreeSelector: string,
|
|
relativePath: string,
|
|
staged: boolean
|
|
): Promise<RuntimeFileOpenResult> {
|
|
const { worktree } = await this.host.resolveRuntimeFileTarget(worktreeSelector)
|
|
if (!isSafeMobileRelativePath(relativePath)) {
|
|
throw new Error('invalid_relative_path')
|
|
}
|
|
const kind = isMobileBinaryPath(relativePath)
|
|
? 'binary'
|
|
: isMobileMarkdownPath(relativePath)
|
|
? 'markdown'
|
|
: 'text'
|
|
const filePath = joinWorktreeRelativePath(worktree.path, relativePath)
|
|
// Why: see openMobileFile; avoid stamping internal runtimeId as runtimeEnvironmentId.
|
|
this.host.openDiff(worktree.id, filePath, relativePath, staged, undefined)
|
|
return { worktree: worktree.id, relativePath, kind, opened: true }
|
|
}
|
|
}
|