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:
Neil
2026-08-07 20:12:17 -07:00
co-authored by Orca
parent a7e31e5e10
commit 752dbd5d93
12 changed files with 600 additions and 160 deletions
@@ -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>
@@ -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
}
+4 -1
View File
@@ -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'
}