mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
`getRepoExecutionHostId` is total: a repo row whose `executionHostId` is present but unparseable (`ssh:`, `ssh:a|b`, a scheme from a future build) falls through to `connectionId`, then to `local`. That collapse sits one layer ABOVE the host-keyed dispatch from #18296, so `UnresolvableExecutionHostError` could never fire on a repo row — dispatch was never asked. `local` means "execute on this machine", so the one wrong answer is the one it gave. Adds `resolveRepoExecutionHostId`, which answers `null` for that row, and uses it at the sites whose job is routing. The total reading is deliberately unchanged: ~340 callers — sidebar grouping, host labels, index and cache keys, set membership — only need a bucket, and for them the fall-through is harmless. Splitting the two keeps them off the strict path instead of making 60 files handle a `null` they have no use for. A malformed id does not fall back to `connectionId`: the row's own declaration is the more specific claim, and recovering a host from the field it overrode is the same guess relocated. `host-repo-catalog-snapshot` already calls that pair a contradiction. Routing sites now refusing rather than executing here: the workspace-cleanup git route, hosted review (`git`/`gh`/`glab`), the `git remote` identity probe, the workspace-space `du`/stat scan, worktree removal and its owner resolution, per-host project removal, local worktree materialization, profile transfer, the renderer's repo update/remove, the paired-client PTY owner, and the two `worktrees:list` calls that would otherwise be answered by the handler's default host. Also, independent of the malformed case: - `resolveWorktreeExecutionHost` gains a `malformed` reason distinct from `unknown`. `unknown` is a verdict the launch path may legitimately dispose of as a plain local folder; `malformed` must fail closed. One word for two situations is the shape that lost the distinction in #18006. - `readAllWorktreeMetaForRepo` / `readWorktreeMetaForRepo` replace four open-coded copies of the same host-qualified read (the F7/F8 lockstep pattern). - `getExecutionHostLabel` answers 'Unknown host' rather than 'All hosts' for an id that names no host. Showing one unroutable row as though it were on every host is wrong on its own terms. Plain English like every other label in that module, none of which resolve through the renderer's i18n catalog. No producer of a malformed id exists in this repo (see the PR body); this is defence at the boundary where the encoding contract is unenforced, not a fix for an observed failure.
279 lines
11 KiB
TypeScript
279 lines
11 KiB
TypeScript
import type { GlobalSettings } from './global-settings-types'
|
|
import type { Repo } from './repo-types'
|
|
import type { Worktree } from './worktree/types'
|
|
|
|
export const LOCAL_EXECUTION_HOST_ID = 'local'
|
|
export const ALL_EXECUTION_HOSTS_SCOPE = 'all'
|
|
|
|
export type ExecutionHostKind = 'local' | 'ssh' | 'runtime'
|
|
export type ExecutionHostId = typeof LOCAL_EXECUTION_HOST_ID | `ssh:${string}` | `runtime:${string}`
|
|
|
|
export type ExecutionHostScope = typeof ALL_EXECUTION_HOSTS_SCOPE | ExecutionHostId
|
|
|
|
export type ParsedExecutionHost =
|
|
| { kind: 'local'; id: typeof LOCAL_EXECUTION_HOST_ID }
|
|
| { kind: 'ssh'; id: `ssh:${string}`; targetId: string }
|
|
| { kind: 'runtime'; id: `runtime:${string}`; environmentId: string }
|
|
|
|
function getCurrentLocalPlatform(): NodeJS.Platform | null {
|
|
const globalNavigator = (globalThis as { navigator?: { userAgent?: string; platform?: string } })
|
|
.navigator
|
|
const userAgent = globalNavigator?.userAgent || globalNavigator?.platform || ''
|
|
if (/Windows/i.test(userAgent)) {
|
|
return 'win32'
|
|
}
|
|
if (/Mac/i.test(userAgent)) {
|
|
return 'darwin'
|
|
}
|
|
if (/Linux|X11/i.test(userAgent)) {
|
|
return 'linux'
|
|
}
|
|
return typeof process === 'undefined' ? null : process.platform
|
|
}
|
|
|
|
export function getLocalExecutionHostLabel(platform: NodeJS.Platform | null = null): string {
|
|
const localPlatform = platform ?? getCurrentLocalPlatform()
|
|
if (localPlatform === 'darwin') {
|
|
return 'Local Mac'
|
|
}
|
|
if (localPlatform === 'win32') {
|
|
return 'Local Windows'
|
|
}
|
|
if (localPlatform === 'linux') {
|
|
return 'Local Linux'
|
|
}
|
|
return 'This computer'
|
|
}
|
|
|
|
function normalizeHostPart(value: string | null | undefined): string | null {
|
|
const trimmed = value?.trim()
|
|
return trimmed ? trimmed : null
|
|
}
|
|
|
|
export function toSshExecutionHostId(targetId: string): `ssh:${string}` {
|
|
return `ssh:${encodeURIComponent(targetId)}`
|
|
}
|
|
|
|
export function toRuntimeExecutionHostId(environmentId: string): `runtime:${string}` {
|
|
return `runtime:${encodeURIComponent(environmentId)}`
|
|
}
|
|
|
|
// Why: runtime-owned (ephemeral-VM) SSH targets are hidden from user-facing
|
|
// SSH/run-target surfaces. The renderer can't read the target.owner field, so it
|
|
// recognizes them by their deterministic id prefix. getRuntimeOwnedSshTargetId
|
|
// (main) builds on this same prefix to keep the two in sync.
|
|
export const RUNTIME_OWNED_SSH_TARGET_ID_PREFIX = 'runtime-ssh-'
|
|
|
|
export function isRuntimeOwnedSshTargetId(targetId: string | null | undefined): boolean {
|
|
return typeof targetId === 'string' && targetId.startsWith(RUNTIME_OWNED_SSH_TARGET_ID_PREFIX)
|
|
}
|
|
|
|
export function parseExecutionHostId(value: string | null | undefined): ParsedExecutionHost | null {
|
|
const normalized = normalizeHostPart(value)
|
|
if (!normalized) {
|
|
return null
|
|
}
|
|
if (normalized === LOCAL_EXECUTION_HOST_ID) {
|
|
return { kind: 'local', id: LOCAL_EXECUTION_HOST_ID }
|
|
}
|
|
if (normalized.startsWith('ssh:')) {
|
|
const encoded = normalized.slice('ssh:'.length)
|
|
if (!encoded) {
|
|
return null
|
|
}
|
|
// `|` must stay out of a host id: composeWorktreeHostIdentity uses it as its delimiter and
|
|
// splits at the first one, so an unencoded pipe would rebind an alias to a different host.
|
|
if (encoded.includes('|')) {
|
|
return null
|
|
}
|
|
try {
|
|
const targetId = decodeURIComponent(encoded)
|
|
return targetId ? { kind: 'ssh', id: `ssh:${encoded}`, targetId } : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
if (normalized.startsWith('runtime:')) {
|
|
const encoded = normalized.slice('runtime:'.length)
|
|
if (!encoded) {
|
|
return null
|
|
}
|
|
if (encoded.includes('|')) {
|
|
return null
|
|
}
|
|
try {
|
|
const environmentId = decodeURIComponent(encoded)
|
|
return environmentId ? { kind: 'runtime', id: `runtime:${encoded}`, environmentId } : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
export function normalizeExecutionHostId(value: string | null | undefined): ExecutionHostId | null {
|
|
return parseExecutionHostId(value)?.id ?? null
|
|
}
|
|
|
|
export function normalizeExecutionHostScope(value: string | null | undefined): ExecutionHostScope {
|
|
const normalized = normalizeHostPart(value)
|
|
if (!normalized || normalized === ALL_EXECUTION_HOSTS_SCOPE) {
|
|
return ALL_EXECUTION_HOSTS_SCOPE
|
|
}
|
|
return normalizeExecutionHostId(normalized) ?? ALL_EXECUTION_HOSTS_SCOPE
|
|
}
|
|
|
|
// An omitted scope on a request means this host, not a fan-out. Callers and the
|
|
// renderer share this so both agree on which requests answer with a merge.
|
|
export function requestedExecutionHostScope(value: string | null | undefined): ExecutionHostScope {
|
|
return normalizeExecutionHostScope(value ?? LOCAL_EXECUTION_HOST_ID)
|
|
}
|
|
|
|
export function normalizeVisibleExecutionHostIds(
|
|
value: readonly string[] | null | undefined
|
|
): ExecutionHostId[] | null {
|
|
if (!Array.isArray(value)) {
|
|
return null
|
|
}
|
|
const ids: ExecutionHostId[] = []
|
|
const seen = new Set<ExecutionHostId>()
|
|
for (const raw of value) {
|
|
const id = normalizeExecutionHostId(raw)
|
|
if (!id || seen.has(id)) {
|
|
continue
|
|
}
|
|
seen.add(id)
|
|
ids.push(id)
|
|
}
|
|
return ids.length > 0 ? ids : null
|
|
}
|
|
|
|
export function normalizeExecutionHostOrder(
|
|
value: readonly string[] | null | undefined
|
|
): ExecutionHostId[] {
|
|
const normalized = normalizeVisibleExecutionHostIds(value)
|
|
return normalized ?? []
|
|
}
|
|
|
|
export function getRepoExecutionHostId(
|
|
repo: Pick<Repo, 'connectionId' | 'executionHostId'>
|
|
): ExecutionHostId {
|
|
const executionHostId = normalizeExecutionHostId(repo.executionHostId)
|
|
if (executionHostId) {
|
|
return executionHostId
|
|
}
|
|
const connectionId = normalizeHostPart(repo.connectionId)
|
|
return connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID
|
|
}
|
|
|
|
/**
|
|
* The same answer for a caller that is about to *route* by it: `null` when the row's
|
|
* `executionHostId` is present but names no parseable host (`ssh:`, `ssh:a|b`, a scheme from a
|
|
* future build).
|
|
*
|
|
* Why a second function rather than making the one above nullable. `getRepoExecutionHostId` is total
|
|
* because ~340 callers — sidebar grouping, host labels, index and cache keys, set membership — only
|
|
* need *a* bucket, and for them the fall-through below is harmless. Routing is the one job where it
|
|
* is not: `local` there means "execute on this machine", so a row that declared a host and then
|
|
* failed to say where must not take the `local` branch. That is the #11163 defect class, and until
|
|
* this existed the host-keyed dispatch in `execution-host-provider-dispatch` could never see it —
|
|
* the collapse happened one layer above, before dispatch was ever asked.
|
|
*
|
|
* A malformed id does not fall back to `connectionId` either: the row's own declaration is the more
|
|
* specific claim, and recovering a host from the field it overrode is the same guess in a new place.
|
|
* `host-repo-catalog-snapshot` already treats that pair as a contradiction.
|
|
*
|
|
* Feed the `null` straight to `resolveGitRouteForHost` / `resolveFilesystemRouteForHost` and they
|
|
* throw `UnresolvableExecutionHostError`; main callers that need the id itself have
|
|
* `requireRepoExecutionHostId`.
|
|
*/
|
|
export function resolveRepoExecutionHostId(
|
|
repo: Pick<Repo, 'connectionId' | 'executionHostId'>
|
|
): ExecutionHostId | null {
|
|
return normalizeHostPart(repo.executionHostId)
|
|
? normalizeExecutionHostId(repo.executionHostId)
|
|
: getRepoExecutionHostId(repo)
|
|
}
|
|
|
|
export function getSshTargetIdForExecutionHost(
|
|
executionHostId: string | null | undefined
|
|
): string | null {
|
|
const parsed = parseExecutionHostId(executionHostId)
|
|
return parsed?.kind === 'ssh' ? parsed.targetId : null
|
|
}
|
|
|
|
// Why: SSH ownership has two spellings on a repo row — the legacy `connectionId`
|
|
// field and the unified `executionHostId`. Routing that reads the raw field answers
|
|
// "local" for a row that only carries `ssh:<target>`, which runs a remote operation
|
|
// on the client. Resolve the host first, then read the connection off it.
|
|
//
|
|
// The two hosts that are not themselves SSH are not the same case:
|
|
//
|
|
// - `local` has no SSH namespace to nest in, so a surviving `connectionId` is a row
|
|
// contradicting itself — the shape main's `resolveRepoOwnershipEvidence` calls
|
|
// `contradictory`. Answering with it hands out an SSH connection for a row that declares
|
|
// itself local.
|
|
// - `runtime:<env>` is a different machine with its own SSH targets, and a nested one appears
|
|
// only in this field (`repoWithFetchedOwner` spreads it through). It is not dialable on its
|
|
// own, but it is addressable as the pair (environmentId, targetId) — which is how the
|
|
// renderer reads it, recovering the environment from the worktree and looking the target up
|
|
// inside it (`selectRuntimeAwareSshStatus`). Dropping it makes a nested-SSH workspace read
|
|
// as local, which is what decides whether a transcript is read on this client.
|
|
//
|
|
// So this answers "which SSH target holds this row's files", not "which connection may this
|
|
// client dial". `getSshTargetIdForExecutionHost` answers the latter; callers routing a
|
|
// client-local PTY or Git provider want that one instead.
|
|
export function getRepoSshConnectionId(
|
|
repo: Pick<Repo, 'connectionId' | 'executionHostId'>
|
|
): string | null {
|
|
const host = parseExecutionHostId(getRepoExecutionHostId(repo))
|
|
if (host?.kind === 'ssh') {
|
|
return host.targetId
|
|
}
|
|
return host?.kind === 'runtime' ? normalizeHostPart(repo.connectionId) : null
|
|
}
|
|
|
|
export function getWorktreeExecutionHostId(
|
|
worktree: Pick<Worktree, 'hostId'>,
|
|
repo: Pick<Repo, 'connectionId' | 'executionHostId'> | undefined,
|
|
defaultHostId: ExecutionHostId = LOCAL_EXECUTION_HOST_ID
|
|
): ExecutionHostId {
|
|
// Why: runtime and SSH snapshots can identify a more precise owner than
|
|
// the repo fallback; every sidebar host decision must use the same precedence.
|
|
return (
|
|
worktree.hostId ??
|
|
(repo?.connectionId || repo?.executionHostId ? getRepoExecutionHostId(repo) : defaultHostId)
|
|
)
|
|
}
|
|
|
|
export function getSettingsFocusedExecutionHostId(
|
|
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
|
|
): ExecutionHostId {
|
|
const runtimeEnvironmentId = normalizeHostPart(settings?.activeRuntimeEnvironmentId)
|
|
return runtimeEnvironmentId
|
|
? toRuntimeExecutionHostId(runtimeEnvironmentId)
|
|
: LOCAL_EXECUTION_HOST_ID
|
|
}
|
|
|
|
export function getExecutionHostLabel(id: ExecutionHostScope | null | undefined): string {
|
|
if (id === ALL_EXECUTION_HOSTS_SCOPE) {
|
|
return 'All hosts'
|
|
}
|
|
const parsed = parseExecutionHostId(id)
|
|
if (!parsed) {
|
|
// Not "All hosts": an id that names no host is one *unknown* host, and answering with the
|
|
// everything-scope label shows an unroutable row as though it were on every host.
|
|
// Plain English like every other label in this module — none of them resolve through the
|
|
// renderer's i18n catalog, and a lone translated string here would read inconsistently.
|
|
return 'Unknown host'
|
|
}
|
|
switch (parsed.kind) {
|
|
case 'local':
|
|
return getLocalExecutionHostLabel()
|
|
case 'ssh':
|
|
return parsed.targetId
|
|
case 'runtime':
|
|
return parsed.environmentId
|
|
}
|
|
}
|