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() 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 ?? [] } // Why the loose member types: a reply reader hands these through as the strings they are on the // wire, and this function is already the thing that decides what an unparseable spelling means. export function getRepoExecutionHostId(repo: { connectionId?: string | null executionHostId?: string | null }): ExecutionHostId { const executionHostId = normalizeExecutionHostId(repo.executionHostId) if (executionHostId) { return executionHostId } const connectionId = normalizeHostPart(repo.connectionId) return connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID } 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:`, 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:` 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 ): 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, repo: Pick | 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 | 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 (`Local Mac`, `This computer`, // `All hosts`) — none of them resolve through the renderer's i18n catalog, so 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 } }