mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +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.
197 lines
6.3 KiB
TypeScript
197 lines
6.3 KiB
TypeScript
// @ts-nocheck -- mechanically split declarations.
|
|
import type { Store } from '../persistence'
|
|
import type {
|
|
ResolvedRuntimeFileTarget,
|
|
ResolvedRuntimeFileWorktree
|
|
} 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'
|
|
import { PhysicalExitTracker } from '../../shared/physical-exit-tracker'
|
|
import {
|
|
MOBILE_BINARY_EXTENSIONS,
|
|
WINDOWS_RUNTIME_FILE_WATCH_CLOSE_DEADLINE_MS,
|
|
WINDOWS_RUNTIME_FILE_WATCH_DEBOUNCE_MS
|
|
} from './runtime-file-commands-mobile-file-list-limit'
|
|
import { watch as watchFs } from 'node:fs'
|
|
import { WatcherProcessFailure } from '../ipc/parcel-watcher-process-failure'
|
|
import { basenameFromRelativePath } from './runtime-file-paths'
|
|
|
|
export type RuntimeFileCommandHost = {
|
|
getRuntimeId(): string
|
|
requireStore(): Store
|
|
resolveWorktreeSelector(selector: string): Promise<ResolvedRuntimeFileWorktree>
|
|
resolveRuntimeFileTarget(selector: string): Promise<ResolvedRuntimeFileTarget>
|
|
resolveKnownWorkspaceFileTarget?(
|
|
absolutePath: string,
|
|
executionHostId: ExecutionHostId
|
|
): Promise<(ResolvedRuntimeFileTarget & { relativePath: string }) | null>
|
|
resolveTerminalCwd?(terminalHandle: string): string | null | Promise<string | null>
|
|
resolveTerminalContext?(
|
|
terminalHandle: string
|
|
): { worktreeId: string; connectionId: string | null } | null
|
|
resolveTerminalFileUriHostname?(terminalHandle: string): string | null | Promise<string | null>
|
|
hasRecentTerminalOutputPath?(
|
|
terminalHandle: string,
|
|
pathText: string,
|
|
absolutePath: string
|
|
): boolean | Promise<boolean>
|
|
hasRecentNativeChatOutputPath?(
|
|
worktreeId: string,
|
|
context: RuntimeNativeChatFileContext,
|
|
pathText: string,
|
|
absolutePath: string
|
|
): boolean | Promise<boolean>
|
|
// `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 }>
|
|
openFile(
|
|
worktreeId: string,
|
|
filePath: string,
|
|
relativePath: string,
|
|
runtimeEnvironmentId?: string | null
|
|
): void
|
|
openDiff(
|
|
worktreeId: string,
|
|
filePath: string,
|
|
relativePath: string,
|
|
staged: boolean,
|
|
runtimeEnvironmentId?: string | null
|
|
): void
|
|
}
|
|
|
|
export function watchWindowsRuntimeFileExplorer(
|
|
rootPath: string,
|
|
callback: (events: FsChangeEvent[]) => void,
|
|
onTerminalError: (error: Error) => void
|
|
): () => Promise<void> {
|
|
let disposed = false
|
|
let timer: ReturnType<typeof setTimeout> | null = null
|
|
let closeStarted = false
|
|
const physicalClose = new PhysicalExitTracker()
|
|
|
|
const emitOverflow = (): void => {
|
|
timer = null
|
|
if (disposed) {
|
|
return
|
|
}
|
|
callback([{ kind: 'overflow', absolutePath: rootPath }])
|
|
}
|
|
|
|
const scheduleOverflow = (): void => {
|
|
if (disposed) {
|
|
return
|
|
}
|
|
if (timer) {
|
|
clearTimeout(timer)
|
|
}
|
|
timer = setTimeout(emitOverflow, WINDOWS_RUNTIME_FILE_WATCH_DEBOUNCE_MS)
|
|
}
|
|
|
|
// Why: Parcel's Watchman probe can crash the headless server on Windows; use a conservative overflow refresh instead.
|
|
const watcher = watchFs(rootPath, { recursive: true }, scheduleOverflow)
|
|
const onClose = (): void => {
|
|
watcher.removeListener('error', onError)
|
|
physicalClose.markExited()
|
|
}
|
|
const onError = (err: Error): void => {
|
|
console.error('[runtime-files.watch] Windows watcher error', { rootPath, err })
|
|
if (timer) {
|
|
clearTimeout(timer)
|
|
timer = null
|
|
}
|
|
watcher.removeListener('close', onClose)
|
|
watcher.removeListener('error', onError)
|
|
// Why: Node nulls FSWatcher's native handle on error without a close event; treat the error as physical-exit proof.
|
|
physicalClose.markExited()
|
|
if (!disposed) {
|
|
try {
|
|
callback([{ kind: 'overflow', absolutePath: rootPath }])
|
|
} finally {
|
|
onTerminalError(err)
|
|
}
|
|
}
|
|
}
|
|
watcher.once('close', onClose)
|
|
watcher.on('error', onError)
|
|
|
|
return async () => {
|
|
disposed = true
|
|
if (timer) {
|
|
clearTimeout(timer)
|
|
timer = null
|
|
}
|
|
if (!closeStarted) {
|
|
try {
|
|
watcher.close()
|
|
} catch (err) {
|
|
console.error('[runtime-files.watch] Windows watcher close error', { rootPath, err })
|
|
throw err
|
|
}
|
|
closeStarted = true
|
|
}
|
|
try {
|
|
await physicalClose.waitForExit(
|
|
WINDOWS_RUNTIME_FILE_WATCH_CLOSE_DEADLINE_MS,
|
|
() => new Error('Windows watcher did not close before deletion deadline')
|
|
)
|
|
} catch (error) {
|
|
// Why: late Windows close still owns native dir handles; expose its completion so cleanup retains then clears the root.
|
|
throw new WatcherProcessFailure(
|
|
error instanceof Error ? error.message : String(error),
|
|
'supervisor',
|
|
'process_unavailable',
|
|
physicalClose.exitedPromise
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
export function isSafeMobileRelativePath(relativePath: string): boolean {
|
|
if (!relativePath || relativePath.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(relativePath)) {
|
|
return false
|
|
}
|
|
const parts = relativePath.replace(/\\/g, '/').split('/')
|
|
return parts.every((part) => part !== '' && part !== '.' && part !== '..')
|
|
}
|
|
|
|
export function isMobileMarkdownPath(relativePath: string): boolean {
|
|
return /\.(md|mdx|markdown)$/i.test(relativePath)
|
|
}
|
|
|
|
export function isMobileBinaryPath(relativePath: string): boolean {
|
|
const basename = basenameFromRelativePath(relativePath)
|
|
const dotIndex = basename.lastIndexOf('.')
|
|
if (dotIndex <= 0) {
|
|
return false
|
|
}
|
|
return MOBILE_BINARY_EXTENSIONS.has(basename.slice(dotIndex).toLowerCase())
|
|
}
|
|
|
|
export function isRuntimeDirectoryEntry(entry: {
|
|
isDirectory(): boolean
|
|
isSymbolicLink(): boolean
|
|
}): boolean {
|
|
// Why: listings are passive UI reads; don't stat symlink targets here (explicit open/expand resolves them).
|
|
if (entry.isSymbolicLink()) {
|
|
return false
|
|
}
|
|
if (entry.isDirectory()) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
export function isBinaryBuffer(buffer: Buffer): boolean {
|
|
const len = Math.min(buffer.length, 8192)
|
|
for (let i = 0; i < len; i += 1) {
|
|
if (buffer[i] === 0) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|