mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 17:17:38 +00:00
* feat(wsl): persist WSL host deployments in their own store and type orcad deployment links by kind * chore(wsl): drop an unused link type export * feat(wsl): add, persist and connect WSL distro hosts behind an experimental flag (WSL-via-orcad C7a) Main-process wiring for WSL hosts: list registered WSL 2 distros, add one (C4 deploy and launch through C6 liveness, then persist the paired environment and its C2 record), keep a relaunch's new device token, connect a host only for the user or an open project or terminal, adopt holders at startup, and remove a host (release its holder, drop both records). Refuses a distro whose orcad this desktop already reaches as another server (R25). * fix(wsl): read distro registrations through one reader that returns the canonical unbraced GUID C4's registry reader returned the braced GUID while the C2 record only accepts it unbraced, and C7 parsed the same output a second time. The one reader now yields lowercase unbraced ids and each registration's WSL version; the host distro list uses it. * fix(wsl): refuse to connect a WSL host while the experiment is off Adding, connecting and background connects all stop while Experimental → WSL distros as hosts is off; removing still works. The pre-deploy refusals move to their own module. * fix(wsl): stop a connected WSL host's server when the host is removed Remove ended the bridge and released the distro's holder but left orcad to exit with the distro. A connected server is now ended right away, only in a distro already running. * fix(wsl): import the orcad host errors from their new orcad/ home * fix(wsl): refuse WSL hosts in a build without the orcad template, and say so up front A build shipped without the orcad deployment template let the add dialog run and fail after seconds with a raw internal error. Main now checks the template as SSH hosts do, refuses add and connect (and skips lazy connects and holder adoption) before any deploy work, and exposes the capability so the UI can show it. * test(wsl): let the e2e WSL fake re-read its distro list, to register one mid-run * fix(wsl): end a removed host's terminals and daemon, and stop WSL hosts when the experiment is off Remove now ends the host's terminals through orcad's terminal RPC, waits for the terminal census to prove none remain, closes the bridge, and runs the managed decommission SSH hosts use, which stops orcad and retires its idle terminal daemon. What it could not end is reported back. Turning Experimental → WSL distros as hosts off closes every connection and liveness watch, releases the holders so distros can idle-stop, and keeps liveness from relaunching for visible work; turning it on resumes only the hosts open work needs. * fix(wsl): keep WSL hosts out of the servers a phone reaches through this desktop They are this desktop's own distros, connected only for work open here; a phone listing or routing to one would wake it behind the user's back. * fix(wsl): end a removed host's terminals through its own route, and never respawn it Removal reached orcad with the bridge's raw pairing offer rather than the routed environment and saved pairing the app already uses for the host; on a real orcad ending terminals failed fast and left them, the daemon and orcad running. It now calls by environment id through the same transport as every other call to the host, and the e2e fake runs these real steps against a headless server. Removal also stops tracking the host first (open work, connects, runtime calls), so nothing reconnects it while it stops, and a spawn guard in the connector refuses any holder, deploy or bridge for a distro whose host is gone (the respawn loop after removal). A WSL host that isn't connected answers runtime calls with why, instead of failing as an unreachable network server. * fix(wsl): suggest reconnecting a WSL host, not Tailscale, when it can't be reached Unreachable-server hints are chosen by the owner host's kind: a WSL host is a distro on this computer, so it points at its reconnect instead of the Tailscale advice for network servers. * test(wsl): parse the terminal list removal reads, and log the e2e fake's connects to a file * fix(wsl): close a removed host's terminals by handle and report stopped only on proof Removal stopped terminals per workspace, so one orphan whose project was gone threw selector_not_found and aborted before orcad was stopped. It now closes each terminal by handle, keeps going past failures and names them, and still asks orcad to stop. It reports stopped only when the census shows no session left, the daemon retired, and the orcad the distro records as active is the one this computer was connected to; a re-add that adopted an orcad the record does not name is reported instead of claimed stopped. * feat(wsl): close WSL hosts behind an internal-only gate in every build WSL distros as hosts must not be reachable by users until moving a project between hosts is lossless and the feature matches today's WSL support. ORCA_INTERNAL_WSL_HOSTS=1 opens the gate; otherwise main creates no WSL host service (no holder adoption, wsl.exe spawn or reconnect), refuses add, connect, remove and every runtime call to a WSL host, keeps persisted WSL hosts out of the server list, startup status probes and AI vault scans while leaving them on disk, and the preload exposes no WSL host API. * fix(wsl): keep SSH and paired servers listed whatever the WSL deployment file holds An unreadable WSL record no longer hides an SSH-linked server, and with the internal gate closed a corrupt WSL file is warned about instead of failing the whole registry. * fix(wsl): let an unreadable WSL record hide only a loopback pairing A WSL host always pairs over a loopback endpoint, so a stale record can no longer remove a remote paired server from the list. * fix(wsl): ignore unreadable WSL deployment state while the internal gate is closed A newer or stale record could otherwise hide a re-paired loopback server for users who never open the gate.
304 lines
11 KiB
TypeScript
304 lines
11 KiB
TypeScript
import type { RuntimeEnvironmentStatus } from './runtime-host-status'
|
|
import {
|
|
LOCAL_EXECUTION_HOST_ID,
|
|
getLocalExecutionHostLabel,
|
|
getRepoExecutionHostId,
|
|
getSettingsFocusedExecutionHostId,
|
|
getSshTargetIdForExecutionHost,
|
|
isRuntimeOwnedSshTargetId,
|
|
normalizeHostPart,
|
|
parseExecutionHostId,
|
|
toRuntimeExecutionHostId,
|
|
toSshExecutionHostId,
|
|
type ExecutionHostId,
|
|
type ExecutionHostKind
|
|
} from './execution-host'
|
|
import { evaluateRuntimeCompat, type RuntimeCompatVerdict } from './protocol-compat'
|
|
import { MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, RUNTIME_PROTOCOL_VERSION } from './protocol-version'
|
|
import type { RuntimeStatus } from './runtime-types'
|
|
import type { SshConnectionState, SshConnectionStatus } from './ssh-types'
|
|
import type { RuntimeEnvironmentSource } from './runtime-environments'
|
|
import type { GlobalSettings } from './global-settings-types'
|
|
import type { Repo } from './repo-types'
|
|
import {
|
|
annotateManagedOrcadExecutionHosts,
|
|
type ManagedOrcadDeploymentSummary
|
|
} from './managed-orcad-execution-host'
|
|
import { getSshConnectionHealth, type ExecutionHostHealth } from './execution-host-health'
|
|
|
|
export type { ExecutionHostHealth } from './execution-host-health'
|
|
|
|
export type ExecutionHostRegistryEntry = {
|
|
id: ExecutionHostId
|
|
kind: ExecutionHostKind
|
|
label: string
|
|
detail: string
|
|
health: ExecutionHostHealth
|
|
connectionStatus?: SshConnectionStatus
|
|
compatibility?: RuntimeCompatVerdict
|
|
capabilities?: readonly string[]
|
|
appVersion?: string | null
|
|
protocolVersion?: number | null
|
|
minCompatibleClientVersion?: number | null
|
|
platform?: NodeJS.Platform | null
|
|
remoteControlState?: RuntimeStatus['remoteControl']
|
|
source?: RuntimeEnvironmentSource
|
|
/** See managed-orcad-execution-host: pairs an SSH host with its managed server. */
|
|
aliasHostIds?: readonly ExecutionHostId[]
|
|
mergedIntoHostId?: ExecutionHostId
|
|
}
|
|
|
|
type RuntimeEnvironmentSummary = {
|
|
id: string
|
|
name?: string | null
|
|
source?: RuntimeEnvironmentSource
|
|
orcadDeployment?: ManagedOrcadDeploymentSummary | null
|
|
}
|
|
|
|
type RuntimeStatusByEnvironmentId = ReadonlyMap<string, RuntimeEnvironmentStatus>
|
|
|
|
export type ExecutionHostSource = 'configured-only' | 'include-references'
|
|
|
|
function runtimeCompatibility(
|
|
status: RuntimeStatus | null | undefined
|
|
): RuntimeCompatVerdict | null {
|
|
if (!status) {
|
|
return null
|
|
}
|
|
return evaluateRuntimeCompat({
|
|
clientProtocolVersion: RUNTIME_PROTOCOL_VERSION,
|
|
minCompatibleServerProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION,
|
|
serverProtocolVersion: status.runtimeProtocolVersion ?? status.protocolVersion,
|
|
serverMinCompatibleClientProtocolVersion:
|
|
status.minCompatibleRuntimeClientVersion ?? status.minCompatibleMobileVersion
|
|
})
|
|
}
|
|
|
|
function runtimeHealth(
|
|
status: RuntimeStatus | null | undefined,
|
|
compatibility: RuntimeCompatVerdict | null,
|
|
remoteControl: RuntimeStatus['remoteControl'] | null | undefined
|
|
): ExecutionHostHealth {
|
|
// Why: with no live status we have no evidence the Orca server is reachable,
|
|
// unless a ready shared-control socket already proved the transport is up.
|
|
if (!status) {
|
|
return remoteControl?.state === 'ready' ? 'available' : 'disconnected'
|
|
}
|
|
if (!compatibility) {
|
|
return 'available'
|
|
}
|
|
return compatibility.kind === 'blocked' ? 'blocked' : 'available'
|
|
}
|
|
|
|
function runtimeControlHealth(
|
|
remoteControl: RuntimeStatus['remoteControl'] | null | undefined
|
|
): ExecutionHostHealth | null {
|
|
switch (remoteControl?.state) {
|
|
case 'awaiting_authenticated':
|
|
case 'awaiting_ready':
|
|
case 'reconnecting':
|
|
return 'connecting'
|
|
case 'closed':
|
|
return remoteControl.lastError ? 'error' : 'disconnected'
|
|
case 'ready':
|
|
return null
|
|
case undefined:
|
|
return null
|
|
}
|
|
}
|
|
|
|
function setHost(
|
|
hosts: Map<ExecutionHostId, ExecutionHostRegistryEntry>,
|
|
entry: ExecutionHostRegistryEntry
|
|
): void {
|
|
const existing = hosts.get(entry.id)
|
|
if (!existing) {
|
|
hosts.set(entry.id, entry)
|
|
return
|
|
}
|
|
if (existing.health !== 'disconnected') {
|
|
return
|
|
}
|
|
// Why: a later status-bearing registration may upgrade health, but the first
|
|
// (named) registration is authoritative for the label — runtime envs are
|
|
// seeded with a friendly name before the id-labeled status/focus/repo
|
|
// fallbacks run, so keep the existing label on a health-only upgrade.
|
|
hosts.set(entry.id, { ...entry, label: existing.label, source: existing.source ?? entry.source })
|
|
}
|
|
|
|
function addRuntimeHost(
|
|
hosts: Map<ExecutionHostId, ExecutionHostRegistryEntry>,
|
|
environmentId: string,
|
|
label: string,
|
|
source: RuntimeEnvironmentSource | undefined,
|
|
statusByEnvironmentId: RuntimeStatusByEnvironmentId | undefined
|
|
): void {
|
|
const hostId = toRuntimeExecutionHostId(environmentId)
|
|
const runtimeStatus = statusByEnvironmentId?.get(environmentId)
|
|
const status = runtimeStatus?.status
|
|
const snapshot = runtimeStatus?.snapshot
|
|
const metadata = status ?? snapshot?.status
|
|
const compatibility = runtimeCompatibility(metadata)
|
|
const remoteControl = runtimeStatus?.remoteControl ?? status?.remoteControl
|
|
// Why: the status owner withdraws `verified` whenever a ready socket drops, so a verified
|
|
// answer proves the host reachable while shared control is still connecting (#10704).
|
|
const reachable =
|
|
snapshot?.transport === 'ready'
|
|
? snapshot.verification !== 'checking'
|
|
: snapshot?.verification === 'verified' && snapshot.transport !== 'disconnected'
|
|
const controlHealth = snapshot?.retired
|
|
? 'disconnected'
|
|
: snapshot?.verification === 'blocked'
|
|
? 'blocked'
|
|
: reachable
|
|
? compatibility?.kind === 'blocked'
|
|
? 'blocked'
|
|
: 'available'
|
|
: !runtimeStatus ||
|
|
snapshot?.verification === 'checking' ||
|
|
snapshot?.transport === 'disconnected' ||
|
|
snapshot?.transport === 'connecting'
|
|
? 'connecting'
|
|
: runtimeControlHealth(remoteControl)
|
|
setHost(hosts, {
|
|
id: hostId,
|
|
kind: 'runtime',
|
|
label,
|
|
detail: 'Orca server',
|
|
health: controlHealth ?? runtimeHealth(status, compatibility, remoteControl),
|
|
compatibility: compatibility ?? undefined,
|
|
capabilities: metadata?.capabilities,
|
|
appVersion: runtimeStatus?.appVersion ?? metadata?.appVersion ?? null,
|
|
protocolVersion: metadata?.runtimeProtocolVersion ?? metadata?.protocolVersion ?? null,
|
|
minCompatibleClientVersion:
|
|
metadata?.minCompatibleRuntimeClientVersion ?? metadata?.minCompatibleMobileVersion ?? null,
|
|
platform: metadata?.hostPlatform ?? null,
|
|
remoteControlState: remoteControl ?? null,
|
|
...(source ? { source } : {})
|
|
})
|
|
}
|
|
|
|
export function buildExecutionHostRegistry(args: {
|
|
repos: readonly Pick<Repo, 'connectionId' | 'executionHostId'>[]
|
|
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
|
|
hostSource?: ExecutionHostSource
|
|
sshTargetLabels?: ReadonlyMap<string, string>
|
|
sshConnectionStates?: ReadonlyMap<string, SshConnectionState>
|
|
runtimeEnvironments?: readonly RuntimeEnvironmentSummary[]
|
|
runtimeStatusByEnvironmentId?: RuntimeStatusByEnvironmentId
|
|
// Why: user-chosen per-host display labels override the derived label so a
|
|
// rename in the host menu/settings shows everywhere the registry feeds.
|
|
hostLabelOverrides?: ReadonlyMap<ExecutionHostId, string>
|
|
}): ExecutionHostRegistryEntry[] {
|
|
const hosts = new Map<ExecutionHostId, ExecutionHostRegistryEntry>()
|
|
hosts.set(LOCAL_EXECUTION_HOST_ID, {
|
|
id: LOCAL_EXECUTION_HOST_ID,
|
|
kind: 'local',
|
|
label: getLocalExecutionHostLabel(),
|
|
detail: 'This computer',
|
|
health: 'local'
|
|
})
|
|
|
|
for (const environment of args.runtimeEnvironments ?? []) {
|
|
const environmentId = normalizeHostPart(environment.id)
|
|
if (!environmentId) {
|
|
continue
|
|
}
|
|
addRuntimeHost(
|
|
hosts,
|
|
environmentId,
|
|
normalizeHostPart(environment.name) ?? environmentId,
|
|
environment.source,
|
|
args.runtimeStatusByEnvironmentId
|
|
)
|
|
}
|
|
for (const environmentId of args.runtimeStatusByEnvironmentId?.keys() ?? []) {
|
|
addRuntimeHost(
|
|
hosts,
|
|
environmentId,
|
|
environmentId,
|
|
undefined,
|
|
args.runtimeStatusByEnvironmentId
|
|
)
|
|
}
|
|
|
|
const focusedHost = getSettingsFocusedExecutionHostId(args.settings)
|
|
const parsedFocusedHost = parseExecutionHostId(focusedHost)
|
|
if (parsedFocusedHost?.kind === 'runtime' && args.hostSource !== 'configured-only') {
|
|
addRuntimeHost(
|
|
hosts,
|
|
parsedFocusedHost.environmentId,
|
|
parsedFocusedHost.environmentId,
|
|
undefined,
|
|
args.runtimeStatusByEnvironmentId
|
|
)
|
|
}
|
|
|
|
const sshTargetIds = new Set<string>()
|
|
if (args.hostSource !== 'configured-only') {
|
|
for (const repo of args.repos) {
|
|
const parsedHost = parseExecutionHostId(repo.executionHostId)
|
|
if (parsedHost?.kind === 'runtime') {
|
|
addRuntimeHost(
|
|
hosts,
|
|
parsedHost.environmentId,
|
|
parsedHost.environmentId,
|
|
undefined,
|
|
args.runtimeStatusByEnvironmentId
|
|
)
|
|
}
|
|
// Why: a VM-backed repo's executionHostId is `ssh:runtime-ssh-<id>`. Runtime-owned
|
|
// targets are hidden, so they must not become visible SSH run-target hosts here.
|
|
if (parsedHost?.kind === 'ssh' && !isRuntimeOwnedSshTargetId(parsedHost.targetId)) {
|
|
sshTargetIds.add(parsedHost.targetId)
|
|
}
|
|
}
|
|
}
|
|
for (const targetId of args.sshTargetLabels?.keys() ?? []) {
|
|
const normalized = normalizeHostPart(targetId)
|
|
if (normalized && !isRuntimeOwnedSshTargetId(normalized)) {
|
|
sshTargetIds.add(normalized)
|
|
}
|
|
}
|
|
if (args.hostSource !== 'configured-only') {
|
|
for (const repo of args.repos) {
|
|
// Why the host, not `connectionId`: a paired-server repo's `connectionId` is the server's
|
|
// own SSH target, which this client cannot dial.
|
|
const targetId = getSshTargetIdForExecutionHost(getRepoExecutionHostId(repo))
|
|
if (targetId && !isRuntimeOwnedSshTargetId(targetId)) {
|
|
sshTargetIds.add(targetId)
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const targetId of sshTargetIds) {
|
|
const state = args.sshConnectionStates?.get(targetId)
|
|
setHost(hosts, {
|
|
id: toSshExecutionHostId(targetId),
|
|
kind: 'ssh',
|
|
label: args.sshTargetLabels?.get(targetId) || targetId,
|
|
detail: 'SSH',
|
|
health: getSshConnectionHealth(state?.status),
|
|
connectionStatus: state?.status
|
|
})
|
|
}
|
|
|
|
annotateManagedOrcadExecutionHosts({
|
|
hosts,
|
|
runtimeEnvironments: args.runtimeEnvironments ?? [],
|
|
sshConnectionStates: args.sshConnectionStates
|
|
})
|
|
|
|
const overrides = args.hostLabelOverrides
|
|
if (!overrides || overrides.size === 0) {
|
|
return [...hosts.values()]
|
|
}
|
|
return [...hosts.values()].map((host) => {
|
|
// Why the alias fallback: a rename saved on the merged-away id still names the merged row.
|
|
const label =
|
|
overrides.get(host.id) ??
|
|
host.aliasHostIds?.map((aliasHostId) => overrides.get(aliasHostId)).find(Boolean)
|
|
return label ? { ...host, label } : host
|
|
})
|
|
}
|