mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
fix(remote): surface a reachable server whose workspace window is closed
Merges #12352's review feedback: one host-health derivation shared by the status bar and Settings. Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -2,6 +2,7 @@ import { useMemo, useState } from 'react'
|
||||
import {
|
||||
getExecutionHostLabel,
|
||||
parseExecutionHostId,
|
||||
toRuntimeExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../../../shared/execution-host'
|
||||
import { buildExecutionHostRegistry } from '../../../../shared/execution-host-registry'
|
||||
@@ -24,6 +25,10 @@ import {
|
||||
selectRuntimeAwareSshStatus,
|
||||
selectRuntimeAwareSshTargetLabel
|
||||
} from '@/store/slices/runtime-environment-ssh'
|
||||
import {
|
||||
isConnectedRuntimeHostState,
|
||||
runtimeHostConnectionState
|
||||
} from '@/runtime/runtime-host-connection-state'
|
||||
|
||||
type RepositoryHostSetupsSectionProps = {
|
||||
repo: Repo
|
||||
@@ -215,9 +220,27 @@ export function RepositoryHostSetupsSection({
|
||||
const runtimeOwnerEnvironmentId =
|
||||
setup.runtimeOwnerEnvironmentId?.trim() ||
|
||||
(transportHost?.kind === 'runtime' ? transportHost.environmentId : null)
|
||||
// Why: share one host-health derivation with the status bar so a degraded
|
||||
// owner can never read "Ready" here and "Connected"/"Disconnected" there.
|
||||
const runtimeOwnerStatusEntry = runtimeOwnerEnvironmentId
|
||||
? runtimeStatusByEnvironmentId.get(runtimeOwnerEnvironmentId)
|
||||
: undefined
|
||||
const runtimeOwnerState = runtimeOwnerEnvironmentId
|
||||
? runtimeHostConnectionState({
|
||||
hasStatusEntry: Boolean(runtimeOwnerStatusEntry),
|
||||
status: runtimeOwnerStatusEntry?.status
|
||||
})
|
||||
: null
|
||||
const runtimeOwnerReachable =
|
||||
!runtimeOwnerEnvironmentId ||
|
||||
Boolean(runtimeStatusByEnvironmentId.get(runtimeOwnerEnvironmentId)?.status)
|
||||
runtimeOwnerState === null || isConnectedRuntimeHostState(runtimeOwnerState)
|
||||
const runtimeOwnerWorkspaceWindowClosed = runtimeOwnerState === 'workspace-window-closed'
|
||||
const runtimeOwnerHostId = runtimeOwnerEnvironmentId
|
||||
? toRuntimeExecutionHostId(runtimeOwnerEnvironmentId)
|
||||
: null
|
||||
const runtimeOwnerHostLabel = runtimeOwnerHostId
|
||||
? (hostOptionById.get(runtimeOwnerHostId)?.label ??
|
||||
getExecutionHostLabel(runtimeOwnerHostId))
|
||||
: ''
|
||||
const nestedSshStatus =
|
||||
runtimeOwnerEnvironmentId && executionHost?.kind === 'ssh'
|
||||
? selectRuntimeAwareSshStatus(
|
||||
@@ -236,20 +259,26 @@ export function RepositoryHostSetupsSection({
|
||||
const setupReady =
|
||||
setup.setupState === 'ready' &&
|
||||
runtimeOwnerReachable &&
|
||||
!runtimeOwnerWorkspaceWindowClosed &&
|
||||
(nestedSshStatus === undefined || nestedSshStatus === 'connected')
|
||||
const setupStateLabel = !runtimeOwnerReachable
|
||||
? translate(
|
||||
'auto.components.settings.RepositoryPane.hostStateDisconnected',
|
||||
'Disconnected'
|
||||
)
|
||||
: nestedSshStatus === null
|
||||
? translate('auto.components.settings.RepositoryPane.hostStateUnknown', 'Unknown')
|
||||
: nestedSshStatus !== undefined && nestedSshStatus !== 'connected'
|
||||
? translate(
|
||||
'auto.components.settings.RepositoryPane.hostStateDisconnected',
|
||||
'Disconnected'
|
||||
)
|
||||
: getSetupStateLabel(setup.setupState)
|
||||
: runtimeOwnerWorkspaceWindowClosed
|
||||
? translate(
|
||||
'auto.components.settings.RepositoryPane.hostStateWorkspaceWindowClosed',
|
||||
'Workspace window closed'
|
||||
)
|
||||
: nestedSshStatus === null
|
||||
? translate('auto.components.settings.RepositoryPane.hostStateUnknown', 'Unknown')
|
||||
: nestedSshStatus !== undefined && nestedSshStatus !== 'connected'
|
||||
? translate(
|
||||
'auto.components.settings.RepositoryPane.hostStateDisconnected',
|
||||
'Disconnected'
|
||||
)
|
||||
: getSetupStateLabel(setup.setupState)
|
||||
const setupHostLabel =
|
||||
runtimeOwnerEnvironmentId && executionHost?.kind === 'ssh'
|
||||
? translate(
|
||||
@@ -299,6 +328,17 @@ export function RepositoryHostSetupsSection({
|
||||
'Path pending'
|
||||
)}
|
||||
</p>
|
||||
{runtimeOwnerWorkspaceWindowClosed ? (
|
||||
// Why: no runtime RPC can open a remote desktop window, so the only
|
||||
// honest affordance is telling the user what to do on that host.
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.RepositoryPane.hostWorkspaceWindowClosedHelp',
|
||||
'The server is reachable but its Orca window is closed. Open Orca on {{value0}} to use this setup.',
|
||||
{ value0: runtimeOwnerHostLabel }
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{isCurrentSetup ? (
|
||||
<SettingsBadge>
|
||||
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
PROJECT_HOST_SETUP_RUNTIME_CAPABILITY,
|
||||
RUNTIME_PROTOCOL_VERSION,
|
||||
WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY
|
||||
} from '../../../../shared/protocol-version'
|
||||
import type { RuntimeStatus } from '../../../../shared/runtime-types'
|
||||
import type { Project, ProjectHostSetup, Repo } from '../../../../shared/types'
|
||||
import { useAppStore } from '../../store'
|
||||
import { RepositoryHostSetupsSection } from './RepositoryHostSetupsSection'
|
||||
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
const repo: Repo = {
|
||||
id: 'remote-repo',
|
||||
displayName: 'Orca',
|
||||
path: '/srv/orca',
|
||||
badgeColor: '#737373',
|
||||
addedAt: 100,
|
||||
kind: 'git',
|
||||
executionHostId: 'runtime:hub'
|
||||
}
|
||||
const project: Project = {
|
||||
id: 'github:stablyai/orca',
|
||||
displayName: 'Orca',
|
||||
badgeColor: '#737373',
|
||||
sourceRepoIds: [repo.id],
|
||||
createdAt: 100,
|
||||
updatedAt: 100
|
||||
}
|
||||
const setup: ProjectHostSetup = {
|
||||
id: 'hub-setup',
|
||||
projectId: project.id,
|
||||
repoId: repo.id,
|
||||
hostId: 'runtime:hub',
|
||||
runtimeOwnerEnvironmentId: 'hub',
|
||||
path: repo.path,
|
||||
displayName: repo.displayName,
|
||||
kind: 'git',
|
||||
setupState: 'ready',
|
||||
setupMethod: 'legacy-repo',
|
||||
createdAt: 100,
|
||||
updatedAt: 100
|
||||
}
|
||||
|
||||
function makeStatus(overrides: Partial<RuntimeStatus>): RuntimeStatus {
|
||||
return {
|
||||
runtimeId: 'runtime-hub',
|
||||
rendererGraphEpoch: 1,
|
||||
graphStatus: 'ready',
|
||||
authoritativeWindowId: 1,
|
||||
desktopWindowStatus: 'available',
|
||||
liveTabCount: 0,
|
||||
liveLeafCount: 0,
|
||||
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
|
||||
minCompatibleRuntimeClientVersion: 1,
|
||||
capabilities: [PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function renderWithOwnerStatus(status: RuntimeStatus | null): void {
|
||||
useAppStore.setState({
|
||||
repos: [repo],
|
||||
projects: [project],
|
||||
projectHostSetups: [setup],
|
||||
runtimeStatusByEnvironmentId: new Map([['hub', { checkedAt: 1, appVersion: '1.8.0', status }]])
|
||||
})
|
||||
act(() => {
|
||||
root.render(
|
||||
React.createElement(RepositoryHostSetupsSection, {
|
||||
repo,
|
||||
forceVisible: true,
|
||||
searchQuery: '',
|
||||
searchEntries: []
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useAppStore.setState(useAppStore.getInitialState(), true)
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount()
|
||||
})
|
||||
container.remove()
|
||||
useAppStore.setState(useAppStore.getInitialState(), true)
|
||||
})
|
||||
|
||||
// Why: #12350 — a reachable remote server whose renderer graph is gone still
|
||||
// answered status.get, so every setup it owned read "Ready".
|
||||
describe('RepositoryHostSetupsSection workspace window availability', () => {
|
||||
it('flags a reachable runtime owner whose workspace window is closed instead of Ready', () => {
|
||||
renderWithOwnerStatus(
|
||||
makeStatus({
|
||||
graphStatus: 'unavailable',
|
||||
authoritativeWindowId: null,
|
||||
desktopWindowStatus: 'openable'
|
||||
})
|
||||
)
|
||||
|
||||
const currentSetup = container.querySelector('[data-current="true"]')
|
||||
expect(currentSetup?.textContent).toContain('Workspace window closed')
|
||||
expect(currentSetup?.textContent).not.toContain('Ready')
|
||||
// The host is reachable — this must not be reported as a lost connection.
|
||||
expect(container.textContent).not.toContain('Disconnected')
|
||||
expect(container.textContent).toContain('Open Orca on')
|
||||
})
|
||||
|
||||
it('keeps a graph-ready runtime owner Ready when it reports no desktop window', () => {
|
||||
// Why: headless `orca serve` (#6844) owns a ready graph with an openable
|
||||
// desktop window — the degraded check must not widen into a renderer requirement.
|
||||
renderWithOwnerStatus(makeStatus({ graphStatus: 'ready', desktopWindowStatus: 'openable' }))
|
||||
|
||||
expect(container.textContent).toContain('Ready')
|
||||
expect(container.textContent).not.toContain('Workspace window closed')
|
||||
expect(container.textContent).not.toContain('Disconnected')
|
||||
})
|
||||
|
||||
it('keeps an unreachable runtime owner disconnected', () => {
|
||||
renderWithOwnerStatus(null)
|
||||
|
||||
expect(container.textContent).toContain('Disconnected')
|
||||
expect(container.textContent).not.toContain('Workspace window closed')
|
||||
})
|
||||
|
||||
it('does not call a setup Ready when the owner control channel closed with an error', () => {
|
||||
renderWithOwnerStatus(
|
||||
makeStatus({
|
||||
remoteControl: {
|
||||
state: 'closed',
|
||||
pendingRequestCount: 0,
|
||||
subscriptionCount: 0,
|
||||
reconnectAttempt: 0,
|
||||
lastConnectedAt: null,
|
||||
lastClose: null,
|
||||
lastError: 'Connection closed'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(container.textContent).toContain('Disconnected')
|
||||
expect(container.textContent).not.toContain('Workspace window closed')
|
||||
})
|
||||
})
|
||||
@@ -30,6 +30,35 @@ describe('RuntimeHostStatusRow', () => {
|
||||
expect(markup).toContain('Connect')
|
||||
})
|
||||
|
||||
it('names the closed workspace window while keeping the disconnect action', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<RuntimeHostStatusRow
|
||||
label="Dev Box"
|
||||
state="workspace-window-closed"
|
||||
onConnect={async () => {}}
|
||||
onDisconnect={async () => {}}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(markup).toContain('Dev Box')
|
||||
expect(markup).toContain('Workspace window closed')
|
||||
expect(markup).toContain('Disconnect')
|
||||
expect(markup).not.toContain('>Connect</button>')
|
||||
// The host is still reachable — the row must not read as a lost connection.
|
||||
expect(markup).not.toContain('Disconnected')
|
||||
// Why: proves the row routes the action to onDisconnect — with only a connect
|
||||
// handler there is nothing to offer, so no button renders.
|
||||
expect(
|
||||
renderToStaticMarkup(
|
||||
<RuntimeHostStatusRow
|
||||
label="Dev Box"
|
||||
state="workspace-window-closed"
|
||||
onConnect={async () => {}}
|
||||
/>
|
||||
)
|
||||
).not.toContain('<button')
|
||||
})
|
||||
|
||||
it('renders connected hosts with a disconnect action', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<RuntimeHostStatusRow label="Dev Box" state="connected" onDisconnect={async () => {}} />
|
||||
|
||||
@@ -2,13 +2,20 @@ import { useCallback, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
|
||||
export type RuntimeHostConnectionState = 'connected' | 'checking' | 'reconnecting' | 'disconnected'
|
||||
import {
|
||||
isConnectedRuntimeHostState,
|
||||
type RuntimeHostConnectionState
|
||||
} from '@/runtime/runtime-host-connection-state'
|
||||
|
||||
function runtimeStatusLabel(state: RuntimeHostConnectionState): string {
|
||||
switch (state) {
|
||||
case 'connected':
|
||||
return translate('auto.components.status.bar.SshStatusSegment.runtime_online', 'Connected')
|
||||
case 'workspace-window-closed':
|
||||
return translate(
|
||||
'auto.components.status.bar.SshStatusSegment.runtime_workspace_window_closed',
|
||||
'Workspace window closed'
|
||||
)
|
||||
case 'checking':
|
||||
return translate('auto.components.status.bar.SshStatusSegment.runtime_checking', 'Checking')
|
||||
case 'reconnecting':
|
||||
@@ -28,6 +35,7 @@ function runtimeDotColor(state: RuntimeHostConnectionState): string {
|
||||
switch (state) {
|
||||
case 'connected':
|
||||
return 'bg-emerald-500'
|
||||
case 'workspace-window-closed':
|
||||
case 'checking':
|
||||
case 'reconnecting':
|
||||
return 'bg-yellow-500'
|
||||
@@ -37,7 +45,7 @@ function runtimeDotColor(state: RuntimeHostConnectionState): string {
|
||||
}
|
||||
|
||||
function runtimeStatusTone(state: RuntimeHostConnectionState): string {
|
||||
if (state === 'checking' || state === 'reconnecting') {
|
||||
if (state === 'checking' || state === 'reconnecting' || state === 'workspace-window-closed') {
|
||||
return 'text-yellow-500'
|
||||
}
|
||||
return 'text-muted-foreground'
|
||||
@@ -46,6 +54,7 @@ function runtimeStatusTone(state: RuntimeHostConnectionState): string {
|
||||
function runtimeActionLabel(state: RuntimeHostConnectionState): string | null {
|
||||
switch (state) {
|
||||
case 'connected':
|
||||
case 'workspace-window-closed':
|
||||
return translate('auto.components.status.bar.SshStatusSegment.59b553e2aa', 'Disconnect')
|
||||
case 'disconnected':
|
||||
return translate('auto.components.status.bar.SshStatusSegment.63f36455cc', 'Connect')
|
||||
@@ -73,7 +82,7 @@ export function RuntimeHostStatusRow({
|
||||
const actionLabel = runtimeActionLabel(state)
|
||||
|
||||
const handleAction = useCallback(async () => {
|
||||
const action = state === 'connected' ? onDisconnect : onConnect
|
||||
const action = isConnectedRuntimeHostState(state) ? onDisconnect : onConnect
|
||||
if (!action) {
|
||||
return
|
||||
}
|
||||
@@ -116,7 +125,7 @@ export function RuntimeHostStatusRow({
|
||||
</div>
|
||||
{busy ? (
|
||||
<Loader2 className="size-3 shrink-0 animate-spin text-muted-foreground" />
|
||||
) : actionLabel && (state === 'connected' ? onDisconnect : onConnect) ? (
|
||||
) : actionLabel && (isConnectedRuntimeHostState(state) ? onDisconnect : onConnect) ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleAction()}
|
||||
|
||||
@@ -1,25 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
connectRuntimeHostForNavigation,
|
||||
isConnectedRuntimeHostState,
|
||||
runtimeStatusForOverall
|
||||
} from './SshStatusSegment'
|
||||
|
||||
describe('SshStatusSegment host status helpers', () => {
|
||||
it('counts connected remote servers as connected hosts', () => {
|
||||
// Why: "connected" = attached/reachable (active-agnostic), matching Settings.
|
||||
// There is no separate "available" state — a reachable host is just Connected.
|
||||
expect(runtimeStatusForOverall('connected')).toBe('connected')
|
||||
expect(isConnectedRuntimeHostState('connected')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps reconnecting and disconnected remote servers out of the connected count', () => {
|
||||
expect(runtimeStatusForOverall('reconnecting')).toBe('connecting')
|
||||
expect(runtimeStatusForOverall('disconnected')).toBe('disconnected')
|
||||
expect(isConnectedRuntimeHostState('reconnecting')).toBe(false)
|
||||
expect(isConnectedRuntimeHostState('disconnected')).toBe(false)
|
||||
})
|
||||
})
|
||||
import { connectRuntimeHostForNavigation } from './SshStatusSegment'
|
||||
|
||||
describe('connectRuntimeHostForNavigation', () => {
|
||||
it('loads the transient host catalog without writing Active Server', async () => {
|
||||
|
||||
@@ -17,134 +17,25 @@ import {
|
||||
toRuntimeExecutionHostId
|
||||
} from '../../../../shared/execution-host'
|
||||
import { isUserManagedRuntimeEnvironment } from '../../../../shared/runtime-environments'
|
||||
import { RuntimeHostStatusRow, type RuntimeHostConnectionState } from './RuntimeHostStatusRow'
|
||||
import { RuntimeHostStatusRow } from './RuntimeHostStatusRow'
|
||||
import {
|
||||
connectedHostCountLabel,
|
||||
connectingHostsLabel,
|
||||
workspaceSyncProblemLabel
|
||||
} from './ssh-status-segment-copy'
|
||||
import { SshTargetStatusRow } from './SshTargetStatusRow'
|
||||
import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../../../shared/remote-runtime-shared-control-types'
|
||||
import { connectRuntimeEnvironmentAndRecordStatus } from './runtime-environment-explicit-connect'
|
||||
import { isConnectingSshStatus } from '@/ssh/ssh-connection-recoverability'
|
||||
|
||||
type HostStatus = 'connected' | 'disconnected' | 'connecting'
|
||||
|
||||
function overallStatus(
|
||||
statuses: HostStatus[]
|
||||
): 'connected' | 'partial' | 'disconnected' | 'connecting' {
|
||||
if (statuses.length === 0) {
|
||||
return 'disconnected'
|
||||
}
|
||||
if (statuses.every((s) => s === 'connected')) {
|
||||
return 'connected'
|
||||
}
|
||||
if (statuses.some((s) => s === 'connecting')) {
|
||||
return 'connecting'
|
||||
}
|
||||
if (statuses.some((s) => s === 'connected')) {
|
||||
return 'partial'
|
||||
}
|
||||
return 'disconnected'
|
||||
}
|
||||
|
||||
function overallDotColor(
|
||||
status: 'connected' | 'partial' | 'disconnected' | 'connecting',
|
||||
connectedCount: number
|
||||
): string {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return 'bg-emerald-500'
|
||||
case 'partial':
|
||||
return connectedCount > 0 ? 'bg-emerald-500' : 'bg-muted-foreground/40'
|
||||
case 'connecting':
|
||||
return 'bg-yellow-500'
|
||||
case 'disconnected':
|
||||
return 'bg-muted-foreground/40'
|
||||
}
|
||||
}
|
||||
|
||||
function sshStatusForOverall(status: SshConnectionStatus): HostStatus {
|
||||
if (status === 'connected') {
|
||||
return 'connected'
|
||||
}
|
||||
return isConnectingSshStatus(status) ? 'connecting' : 'disconnected'
|
||||
}
|
||||
|
||||
function runtimeHostConnectionState({
|
||||
hasStatus,
|
||||
online,
|
||||
remoteControl
|
||||
}: {
|
||||
hasStatus: boolean
|
||||
online: boolean
|
||||
remoteControl?: RemoteRuntimeSharedConnectionDiagnostics | null
|
||||
}): RuntimeHostConnectionState {
|
||||
if (!hasStatus) {
|
||||
return 'checking'
|
||||
}
|
||||
if (remoteControl?.state === 'reconnecting') {
|
||||
return 'reconnecting'
|
||||
}
|
||||
if (!online) {
|
||||
return 'disconnected'
|
||||
}
|
||||
if (remoteControl?.state === 'closed' && remoteControl.lastError) {
|
||||
return 'disconnected'
|
||||
}
|
||||
// Why: "connected" means attached/reachable, NOT "is the active default host".
|
||||
// Both surfaces (this status bar and Settings > Remote Orca Servers) must agree
|
||||
// on that single definition, or a reachable-but-not-active host reads
|
||||
// "Connected" in one place and "Available" in the other. Active/default is a
|
||||
// separate concept (surfaced elsewhere), so it must not change this state.
|
||||
return 'connected'
|
||||
}
|
||||
|
||||
function runtimeHostConnectionDetail(
|
||||
remoteControl?: RemoteRuntimeSharedConnectionDiagnostics | null
|
||||
): string | undefined {
|
||||
if (!remoteControl) {
|
||||
return undefined
|
||||
}
|
||||
if (remoteControl.lastError) {
|
||||
return remoteControl.lastError
|
||||
}
|
||||
if (remoteControl.lastClose?.reason) {
|
||||
return translate(
|
||||
'auto.components.status.bar.SshStatusSegment.runtime_last_close_reason',
|
||||
'Closed: {{value0}}',
|
||||
{ value0: remoteControl.lastClose.reason }
|
||||
)
|
||||
}
|
||||
if (remoteControl.state === 'reconnecting') {
|
||||
return translate(
|
||||
'auto.components.status.bar.SshStatusSegment.runtime_reconnect_attempt',
|
||||
'Attempt {{value0}}',
|
||||
{ value0: String(remoteControl.reconnectAttempt + 1) }
|
||||
)
|
||||
}
|
||||
// Why: pending-request / subscription counts are internal RPC plumbing (e.g. a
|
||||
// live browser screencast shows as "N streams"). They're noise in a user-facing
|
||||
// status row and make the line truncate — only surface actionable detail
|
||||
// (errors, close reasons, reconnect attempts) above.
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function runtimeStatusForOverall(state: RuntimeHostConnectionState): HostStatus {
|
||||
switch (state) {
|
||||
case 'connected':
|
||||
return 'connected'
|
||||
case 'checking':
|
||||
case 'reconnecting':
|
||||
return 'connecting'
|
||||
case 'disconnected':
|
||||
return 'disconnected'
|
||||
}
|
||||
}
|
||||
|
||||
export function isConnectedRuntimeHostState(state: RuntimeHostConnectionState): boolean {
|
||||
return state === 'connected'
|
||||
}
|
||||
import {
|
||||
overallDotColor,
|
||||
overallStatus,
|
||||
runtimeHostConnectionDetail,
|
||||
sshStatusForOverall
|
||||
} from './remote-host-connection-status'
|
||||
import {
|
||||
isConnectedRuntimeHostState,
|
||||
runtimeHostConnectionState,
|
||||
runtimeStatusForOverall
|
||||
} from '@/runtime/runtime-host-connection-state'
|
||||
|
||||
export async function connectRuntimeHostForNavigation(args: {
|
||||
environmentId: string
|
||||
@@ -205,8 +96,8 @@ export function SshStatusSegment({
|
||||
return {
|
||||
id: environment.id,
|
||||
label: override || environment.name || environment.id,
|
||||
hasStatus: Boolean(statusEntry),
|
||||
online: Boolean(statusEntry?.status),
|
||||
hasStatusEntry: Boolean(statusEntry),
|
||||
status: statusEntry?.status ?? null,
|
||||
active: settings?.activeRuntimeEnvironmentId === environment.id,
|
||||
remoteControl: statusEntry?.status?.remoteControl ?? null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { SshConnectionStatus } from '../../../../shared/ssh-types'
|
||||
import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../../../shared/remote-runtime-shared-control-types'
|
||||
import type { HostStatus } from '@/runtime/runtime-host-connection-state'
|
||||
import { isConnectingSshStatus } from '@/ssh/ssh-connection-recoverability'
|
||||
|
||||
export function overallStatus(
|
||||
statuses: HostStatus[]
|
||||
): 'connected' | 'partial' | 'disconnected' | 'connecting' {
|
||||
if (statuses.length === 0) {
|
||||
return 'disconnected'
|
||||
}
|
||||
if (statuses.every((s) => s === 'connected')) {
|
||||
return 'connected'
|
||||
}
|
||||
if (statuses.some((s) => s === 'connecting')) {
|
||||
return 'connecting'
|
||||
}
|
||||
if (statuses.some((s) => s === 'connected')) {
|
||||
return 'partial'
|
||||
}
|
||||
return 'disconnected'
|
||||
}
|
||||
|
||||
export function overallDotColor(
|
||||
status: 'connected' | 'partial' | 'disconnected' | 'connecting',
|
||||
connectedCount: number
|
||||
): string {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return 'bg-emerald-500'
|
||||
case 'partial':
|
||||
return connectedCount > 0 ? 'bg-emerald-500' : 'bg-muted-foreground/40'
|
||||
case 'connecting':
|
||||
return 'bg-yellow-500'
|
||||
case 'disconnected':
|
||||
return 'bg-muted-foreground/40'
|
||||
}
|
||||
}
|
||||
|
||||
export function sshStatusForOverall(status: SshConnectionStatus): HostStatus {
|
||||
if (status === 'connected') {
|
||||
return 'connected'
|
||||
}
|
||||
return isConnectingSshStatus(status) ? 'connecting' : 'disconnected'
|
||||
}
|
||||
|
||||
export function runtimeHostConnectionDetail(
|
||||
remoteControl?: RemoteRuntimeSharedConnectionDiagnostics | null
|
||||
): string | undefined {
|
||||
if (!remoteControl) {
|
||||
return undefined
|
||||
}
|
||||
if (remoteControl.lastError) {
|
||||
return remoteControl.lastError
|
||||
}
|
||||
if (remoteControl.lastClose?.reason) {
|
||||
return translate(
|
||||
'auto.components.status.bar.SshStatusSegment.runtime_last_close_reason',
|
||||
'Closed: {{value0}}',
|
||||
{ value0: remoteControl.lastClose.reason }
|
||||
)
|
||||
}
|
||||
if (remoteControl.state === 'reconnecting') {
|
||||
return translate(
|
||||
'auto.components.status.bar.SshStatusSegment.runtime_reconnect_attempt',
|
||||
'Attempt {{value0}}',
|
||||
{ value0: String(remoteControl.reconnectAttempt + 1) }
|
||||
)
|
||||
}
|
||||
// Why: pending-request / subscription counts are internal RPC plumbing (e.g. a
|
||||
// live browser screencast shows as "N streams"). They're noise in a user-facing
|
||||
// status row and make the line truncate — only surface actionable detail
|
||||
// (errors, close reasons, reconnect attempts) above.
|
||||
return undefined
|
||||
}
|
||||
@@ -3279,6 +3279,7 @@
|
||||
"runtime_reconnecting": "Reconnecting",
|
||||
"runtime_last_close_reason": "Closed: {{value0}}",
|
||||
"runtime_reconnect_attempt": "Attempt {{value0}}",
|
||||
"runtime_workspace_window_closed": "Workspace window closed",
|
||||
"connectedHostCount_one": "{{count}} host",
|
||||
"connectedHostCount_other": "{{count}} hosts",
|
||||
"connecting": "Connecting…",
|
||||
@@ -6860,7 +6861,9 @@
|
||||
"projectRuntimeDescription": "Choose whether this project runs on Windows or WSL.",
|
||||
"hostStateDisconnected": "Disconnected",
|
||||
"hostStateUnknown": "Unknown",
|
||||
"nestedHostLabel": "{{value0}} via {{value1}}"
|
||||
"nestedHostLabel": "{{value0}} via {{value1}}",
|
||||
"hostStateWorkspaceWindowClosed": "Workspace window closed",
|
||||
"hostWorkspaceWindowClosedHelp": "The server is reachable but its Orca window is closed. Open Orca on {{value0}} to use this setup."
|
||||
},
|
||||
"RepositorySourceControlAiActionRows": {
|
||||
"548a6e1281": "Command template",
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RuntimeStatus } from '../../../shared/runtime-types'
|
||||
import {
|
||||
isConnectedRuntimeHostState,
|
||||
runtimeHostConnectionState,
|
||||
runtimeStatusForOverall
|
||||
} from './runtime-host-connection-state'
|
||||
|
||||
function makeStatus(overrides: Partial<RuntimeStatus> = {}): RuntimeStatus {
|
||||
return {
|
||||
runtimeId: 'runtime-hub',
|
||||
rendererGraphEpoch: 1,
|
||||
graphStatus: 'ready',
|
||||
authoritativeWindowId: 1,
|
||||
desktopWindowStatus: 'available',
|
||||
liveTabCount: 0,
|
||||
liveLeafCount: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const windowClosedStatus = makeStatus({
|
||||
graphStatus: 'unavailable',
|
||||
authoritativeWindowId: null,
|
||||
desktopWindowStatus: 'openable'
|
||||
})
|
||||
|
||||
describe('runtime host connection state', () => {
|
||||
it('counts connected remote servers as connected hosts', () => {
|
||||
// Why: "connected" = attached/reachable (active-agnostic), matching Settings.
|
||||
// There is no separate "available" state — a reachable host is just Connected.
|
||||
expect(runtimeStatusForOverall('connected')).toBe('connected')
|
||||
expect(isConnectedRuntimeHostState('connected')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps reconnecting and disconnected remote servers out of the connected count', () => {
|
||||
expect(runtimeStatusForOverall('reconnecting')).toBe('connecting')
|
||||
expect(runtimeStatusForOverall('disconnected')).toBe('disconnected')
|
||||
expect(isConnectedRuntimeHostState('reconnecting')).toBe(false)
|
||||
expect(isConnectedRuntimeHostState('disconnected')).toBe(false)
|
||||
})
|
||||
|
||||
it('still counts a workspace-window-closed remote server as a connected host', () => {
|
||||
// Why: the transport is healthy, so demoting it to disconnected would be a lie
|
||||
// in the other direction — only the wording changes (#12350).
|
||||
expect(runtimeStatusForOverall('workspace-window-closed')).toBe('connected')
|
||||
expect(isConnectedRuntimeHostState('workspace-window-closed')).toBe(true)
|
||||
})
|
||||
|
||||
it('distinguishes a reachable host whose workspace window is closed', () => {
|
||||
expect(runtimeHostConnectionState({ hasStatusEntry: true, status: windowClosedStatus })).toBe(
|
||||
'workspace-window-closed'
|
||||
)
|
||||
expect(runtimeHostConnectionState({ hasStatusEntry: true, status: makeStatus() })).toBe(
|
||||
'connected'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps transport failures ahead of a closed workspace window', () => {
|
||||
expect(runtimeHostConnectionState({ hasStatusEntry: false, status: null })).toBe('checking')
|
||||
expect(runtimeHostConnectionState({ hasStatusEntry: true, status: null })).toBe('disconnected')
|
||||
expect(
|
||||
runtimeHostConnectionState({
|
||||
hasStatusEntry: true,
|
||||
status: {
|
||||
...windowClosedStatus,
|
||||
remoteControl: {
|
||||
state: 'reconnecting',
|
||||
pendingRequestCount: 0,
|
||||
subscriptionCount: 0,
|
||||
reconnectAttempt: 0,
|
||||
lastConnectedAt: null,
|
||||
lastClose: null,
|
||||
lastError: null
|
||||
}
|
||||
}
|
||||
})
|
||||
).toBe('reconnecting')
|
||||
})
|
||||
|
||||
it('does not hide a closed control channel behind workspace-window diagnostics', () => {
|
||||
expect(
|
||||
runtimeHostConnectionState({
|
||||
hasStatusEntry: true,
|
||||
status: {
|
||||
...windowClosedStatus,
|
||||
remoteControl: {
|
||||
state: 'closed',
|
||||
pendingRequestCount: 0,
|
||||
subscriptionCount: 0,
|
||||
reconnectAttempt: 0,
|
||||
lastConnectedAt: null,
|
||||
lastClose: null,
|
||||
lastError: 'Connection closed'
|
||||
}
|
||||
}
|
||||
})
|
||||
).toBe('disconnected')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { RuntimeStatus } from '../../../shared/runtime-types'
|
||||
import { isRuntimeWorkspaceWindowClosed } from '../../../shared/runtime-workspace-window-availability'
|
||||
|
||||
export type HostStatus = 'connected' | 'disconnected' | 'connecting'
|
||||
|
||||
// Why: 'workspace-window-closed' is a reachable host that cannot serve graph-backed
|
||||
// work — connected for counting purposes, but not interchangeable with 'connected'.
|
||||
export type RuntimeHostConnectionState =
|
||||
| 'connected'
|
||||
| 'workspace-window-closed'
|
||||
| 'checking'
|
||||
| 'reconnecting'
|
||||
| 'disconnected'
|
||||
|
||||
// Why: one derivation for every host surface (status bar + Settings > Available Hosts),
|
||||
// so a degraded host can never read "Connected" in one place and "Ready" in the other.
|
||||
export function runtimeHostConnectionState({
|
||||
hasStatusEntry,
|
||||
status
|
||||
}: {
|
||||
hasStatusEntry: boolean
|
||||
status: RuntimeStatus | null | undefined
|
||||
}): RuntimeHostConnectionState {
|
||||
if (!hasStatusEntry) {
|
||||
return 'checking'
|
||||
}
|
||||
const remoteControl = status?.remoteControl
|
||||
if (remoteControl?.state === 'reconnecting') {
|
||||
return 'reconnecting'
|
||||
}
|
||||
if (!status) {
|
||||
return 'disconnected'
|
||||
}
|
||||
if (remoteControl?.state === 'closed' && remoteControl.lastError) {
|
||||
return 'disconnected'
|
||||
}
|
||||
// Why: reachable but graph-less — the transport is fine, so this is not a network
|
||||
// disconnect, but calling it "Connected" hides that nothing will run there.
|
||||
if (isRuntimeWorkspaceWindowClosed(status)) {
|
||||
return 'workspace-window-closed'
|
||||
}
|
||||
// Why: "connected" means attached/reachable, NOT "is the active default host".
|
||||
// Both surfaces must agree on that single definition, or a reachable-but-not-active
|
||||
// host reads "Connected" in one place and "Available" in the other. Active/default is
|
||||
// a separate concept (surfaced elsewhere), so it must not change this state.
|
||||
return 'connected'
|
||||
}
|
||||
|
||||
export function runtimeStatusForOverall(state: RuntimeHostConnectionState): HostStatus {
|
||||
switch (state) {
|
||||
// Why: a closed workspace window is a degraded host, not a lost connection —
|
||||
// it must keep counting toward the connected-host total.
|
||||
case 'connected':
|
||||
case 'workspace-window-closed':
|
||||
return 'connected'
|
||||
case 'checking':
|
||||
case 'reconnecting':
|
||||
return 'connecting'
|
||||
case 'disconnected':
|
||||
return 'disconnected'
|
||||
}
|
||||
}
|
||||
|
||||
export function isConnectedRuntimeHostState(state: RuntimeHostConnectionState): boolean {
|
||||
return state === 'connected' || state === 'workspace-window-closed'
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RuntimeStatus } from './runtime-types'
|
||||
import { isRuntimeWorkspaceWindowClosed } from './runtime-workspace-window-availability'
|
||||
|
||||
function makeStatus(overrides: Partial<RuntimeStatus>): RuntimeStatus {
|
||||
return {
|
||||
runtimeId: 'runtime-hub',
|
||||
rendererGraphEpoch: 1,
|
||||
graphStatus: 'ready',
|
||||
authoritativeWindowId: 1,
|
||||
liveTabCount: 0,
|
||||
liveLeafCount: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('isRuntimeWorkspaceWindowClosed', () => {
|
||||
it('flags a reachable host whose graph is gone but whose desktop window can be opened', () => {
|
||||
expect(
|
||||
isRuntimeWorkspaceWindowClosed(
|
||||
makeStatus({
|
||||
graphStatus: 'unavailable',
|
||||
authoritativeWindowId: null,
|
||||
desktopWindowStatus: 'openable'
|
||||
})
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves graph-ready headless servers alone', () => {
|
||||
// Why: #6844 headless serve owns a graph without a desktop window — it must
|
||||
// never read as degraded just because a window could be opened.
|
||||
expect(
|
||||
isRuntimeWorkspaceWindowClosed(
|
||||
makeStatus({ graphStatus: 'ready', desktopWindowStatus: 'openable' })
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores a renderer reload that still owns its window', () => {
|
||||
expect(
|
||||
isRuntimeWorkspaceWindowClosed(
|
||||
makeStatus({ graphStatus: 'reloading', desktopWindowStatus: 'available' })
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('flags a reload whose window disappeared before the graph was marked unavailable', () => {
|
||||
// Why: 'openable' already means no live renderer window, so a mid-reload window
|
||||
// teardown is just as unusable as 'unavailable'.
|
||||
expect(
|
||||
isRuntimeWorkspaceWindowClosed(
|
||||
makeStatus({ graphStatus: 'reloading', desktopWindowStatus: 'openable' })
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('treats a missing desktop window claim as no claim', () => {
|
||||
expect(isRuntimeWorkspaceWindowClosed(makeStatus({ graphStatus: 'unavailable' }))).toBe(false)
|
||||
expect(
|
||||
isRuntimeWorkspaceWindowClosed(
|
||||
makeStatus({ graphStatus: 'unavailable', desktopWindowStatus: 'initializing' })
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
isRuntimeWorkspaceWindowClosed(
|
||||
makeStatus({ graphStatus: 'unavailable', desktopWindowStatus: 'blocked' })
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('is not a substitute for an unreachable host', () => {
|
||||
expect(isRuntimeWorkspaceWindowClosed(null)).toBe(false)
|
||||
expect(isRuntimeWorkspaceWindowClosed(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { RuntimeStatus } from './runtime-types'
|
||||
|
||||
// Why: a headed Orca server keeps answering status.get after its workspace window
|
||||
// closes, so "a status came back" is not evidence that graph-backed workspace work
|
||||
// will succeed there. desktopWindowStatus is only 'openable' when no live renderer
|
||||
// window exists (orca-runtime.ts:4806), so pairing it with a non-ready graph is the
|
||||
// narrow "reachable, but nothing can run" signal: a graph-ready headless serve
|
||||
// (#6844) and hosts that omit desktopWindowStatus (older builds) stay untouched.
|
||||
export function isRuntimeWorkspaceWindowClosed(status: RuntimeStatus | null | undefined): boolean {
|
||||
if (!status) {
|
||||
return false
|
||||
}
|
||||
return status.graphStatus !== 'ready' && status.desktopWindowStatus === 'openable'
|
||||
}
|
||||
Reference in New Issue
Block a user