mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(runtime): publish remote control outages to host surfaces (#17531)
* fix(runtime): publish remote control diagnostics to renderer * test(runtime): account for diagnostics bridge listener * fix(i18n): add runtime connection state labels * test(runtime): clean up shared control connection * fix(runtime): fence diagnostics by shared-control capability * fix(runtime): preserve authoritative transport state * fix(runtime): preserve diagnostic overlay lifecycle * fix(runtime): avoid publishing unchanged diagnostics state --------- Co-authored-by: Merge Sim <sim@local>
This commit is contained in:
co-authored by
Merge Sim
parent
db84894eef
commit
aabcc57366
@@ -245,6 +245,7 @@ export function formatCliStatus(status: CliStatusResult): string {
|
||||
`desktopWindowStatus: ${status.app.desktopWindowStatus ?? 'unknown'}`,
|
||||
`runtimeState: ${status.runtime.state}`,
|
||||
`runtimeReachable: ${status.runtime.reachable}`,
|
||||
`runtimeConnectionState: ${status.runtime.connectionState ?? 'unknown'}`,
|
||||
`runtimeId: ${status.runtime.runtimeId ?? 'none'}`,
|
||||
`graphState: ${status.graph.state}`
|
||||
].join('\n')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types'
|
||||
import { runtimeHostConnectionState } from '../../shared/runtime-host-connection-state'
|
||||
import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope'
|
||||
import {
|
||||
isOrchestrationMutation,
|
||||
@@ -207,6 +208,10 @@ export class RuntimeClient {
|
||||
runtime: {
|
||||
state: graphState === 'ready' ? 'ready' : 'graph_not_ready',
|
||||
reachable: true,
|
||||
connectionState: runtimeHostConnectionState({
|
||||
hasStatusEntry: true,
|
||||
status: response.result
|
||||
}),
|
||||
runtimeId: response.result.runtimeId,
|
||||
...(response.result.appVersion ? { appVersion: response.result.appVersion } : {}),
|
||||
...(response.result.remoteUpdateSupport
|
||||
|
||||
@@ -78,6 +78,7 @@ describe.skipIf(process.platform === 'win32')('CLI runtime status', () => {
|
||||
|
||||
expect(status.result.runtime).toMatchObject({
|
||||
reachable: true,
|
||||
connectionState: 'connected',
|
||||
runtimeId: 'runtime-legacy',
|
||||
state: 'ready',
|
||||
degradations: [expect.objectContaining({ code: 'browser_unavailable' })]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types'
|
||||
import { runtimeHostConnectionState } from '../../shared/runtime-host-connection-state'
|
||||
import { findTransport } from '../../shared/runtime-bootstrap'
|
||||
import { tryReadMetadata } from './metadata'
|
||||
import { sendRequest } from './transport'
|
||||
@@ -51,6 +52,10 @@ export async function getCliStatus(
|
||||
runtime: {
|
||||
state: graphState === 'ready' ? 'ready' : 'graph_not_ready',
|
||||
reachable: true,
|
||||
connectionState: runtimeHostConnectionState({
|
||||
hasStatusEntry: true,
|
||||
status: response.result
|
||||
}),
|
||||
runtimeId: response.result.runtimeId,
|
||||
...(response.result.appVersion ? { appVersion: response.result.appVersion } : {}),
|
||||
...(response.result.remoteUpdateSupport
|
||||
@@ -73,6 +78,7 @@ export async function getCliStatus(
|
||||
runtime: {
|
||||
state: running ? 'starting' : 'stale_bootstrap',
|
||||
reachable: false,
|
||||
connectionState: 'disconnected',
|
||||
runtimeId: null
|
||||
},
|
||||
graph: {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const getAllWindows = vi.hoisted(() => vi.fn())
|
||||
vi.mock('electron', () => ({ BrowserWindow: { getAllWindows } }))
|
||||
|
||||
import {
|
||||
publishRuntimeEnvironmentDiagnostics,
|
||||
RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL
|
||||
} from './runtime-environment-diagnostics-broadcast'
|
||||
|
||||
describe('runtime environment diagnostics broadcast', () => {
|
||||
beforeEach(() => getAllWindows.mockReset())
|
||||
|
||||
it('publishes to live renderer windows and skips destroyed windows', () => {
|
||||
const live = { isDestroyed: () => false, webContents: { send: vi.fn() } }
|
||||
const destroyed = { isDestroyed: () => true, webContents: { send: vi.fn() } }
|
||||
getAllWindows.mockReturnValue([live, destroyed])
|
||||
const event = {
|
||||
environmentId: 'env-a',
|
||||
transportGeneration: 2,
|
||||
diagnostics: {
|
||||
state: 'reconnecting' as const,
|
||||
pendingRequestCount: 0,
|
||||
subscriptionCount: 1,
|
||||
reconnectAttempt: 1,
|
||||
lastConnectedAt: 1,
|
||||
lastClose: null,
|
||||
lastError: 'offline'
|
||||
}
|
||||
}
|
||||
|
||||
publishRuntimeEnvironmentDiagnostics(event)
|
||||
|
||||
expect(live.webContents.send).toHaveBeenCalledWith(
|
||||
RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL,
|
||||
event
|
||||
)
|
||||
expect(destroyed.webContents.send).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { BrowserWindow } from 'electron'
|
||||
import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../shared/remote-runtime-shared-control-types'
|
||||
import { RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL } from '../../shared/runtime-environment-diagnostics'
|
||||
|
||||
export { RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL }
|
||||
|
||||
export type RuntimeEnvironmentDiagnosticsEvent = {
|
||||
environmentId: string
|
||||
transportGeneration: number
|
||||
diagnostics: RemoteRuntimeSharedConnectionDiagnostics
|
||||
}
|
||||
|
||||
export function publishRuntimeEnvironmentDiagnostics(
|
||||
event: RuntimeEnvironmentDiagnosticsEvent
|
||||
): void {
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
if (window.isDestroyed()) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
window.webContents.send(RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL, event)
|
||||
} catch {
|
||||
// A renderer can disappear between isDestroyed() and send().
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,11 @@ import type {
|
||||
} from '../../shared/remote-runtime-shared-control-types'
|
||||
import { isRuntimeEnvironmentCapabilityPaused } from './runtime-environment-capability-evidence'
|
||||
import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-manual-disconnect'
|
||||
import { publishRuntimeEnvironmentDiagnostics } from './runtime-environment-diagnostics-broadcast'
|
||||
import {
|
||||
advanceRuntimeEnvironmentTransportGeneration,
|
||||
getRuntimeEnvironmentTransportGeneration
|
||||
} from './runtime-environment-transport-generation'
|
||||
|
||||
type CachedRuntimeConnection = {
|
||||
pairingKey: string
|
||||
@@ -142,14 +147,26 @@ function getSharedControlConnection(
|
||||
const pairingKey = getPairingKey(pairing)
|
||||
let cached = sharedControlConnections.get(environmentId)
|
||||
if (!cached || cached.pairingKey !== pairingKey) {
|
||||
advanceRuntimeEnvironmentTransportGeneration(environmentId)
|
||||
cached?.connection.close()
|
||||
const transportGeneration = getRuntimeEnvironmentTransportGeneration(environmentId)
|
||||
cached = {
|
||||
pairingKey,
|
||||
connection: new RemoteRuntimeSharedControlConnection(pairing, {
|
||||
environmentId,
|
||||
clientCapabilities: ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES,
|
||||
isManuallyDisconnected: () => isRuntimeEnvironmentManuallyDisconnected(environmentId),
|
||||
isCapabilityPaused: () => isRuntimeEnvironmentCapabilityPaused(environmentId)
|
||||
isCapabilityPaused: () => isRuntimeEnvironmentCapabilityPaused(environmentId),
|
||||
onDiagnosticsChanged: (diagnostics) => {
|
||||
if (getRuntimeEnvironmentTransportGeneration(environmentId) !== transportGeneration) {
|
||||
return
|
||||
}
|
||||
publishRuntimeEnvironmentDiagnostics({
|
||||
environmentId,
|
||||
transportGeneration,
|
||||
diagnostics
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
sharedControlConnections.set(environmentId, cached)
|
||||
|
||||
@@ -37,6 +37,7 @@ function formatStatusResult(status: CliStatusResult): { stdout: string; stderr:
|
||||
`desktopWindowStatus: ${status.app.desktopWindowStatus ?? 'unknown'}`,
|
||||
`runtimeState: ${status.runtime.state}`,
|
||||
`runtimeReachable: ${status.runtime.reachable}`,
|
||||
`runtimeConnectionState: ${status.runtime.connectionState ?? 'unknown'}`,
|
||||
`runtimeId: ${status.runtime.runtimeId ?? 'none'}`,
|
||||
`graphState: ${status.graph.state}`
|
||||
].join('\n')}\n`,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types'
|
||||
import { runtimeHostConnectionState } from '../../shared/runtime-host-connection-state'
|
||||
import { projectRemoteAppStatus } from '../../shared/cli-app-status-projection'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope'
|
||||
@@ -182,6 +183,7 @@ async function dispatchRemoteCli(
|
||||
runtime: {
|
||||
state: status.graphStatus === 'ready' ? 'ready' : 'graph_not_ready',
|
||||
reachable: true,
|
||||
connectionState: runtimeHostConnectionState({ hasStatusEntry: true, status }),
|
||||
runtimeId: status.runtimeId
|
||||
},
|
||||
graph: { state: status.graphStatus }
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
BrowserClientHostPlacementPreparationRequest,
|
||||
BrowserPageCreationPlacement
|
||||
} from '../../shared/browser-client-host-placement'
|
||||
import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../shared/remote-runtime-shared-control-types'
|
||||
|
||||
export type RuntimeEnvironmentSubscriptionHandle = {
|
||||
unsubscribe: () => void
|
||||
@@ -101,6 +102,13 @@ export type RuntimeApi = {
|
||||
observeOnly?: true
|
||||
}) => Promise<RuntimeRpcResponse<RuntimeStatus>>
|
||||
retryControlConnection?: (args: { selector: string }) => Promise<void>
|
||||
onSharedControlDiagnostics?: (
|
||||
callback: (event: {
|
||||
environmentId: string
|
||||
transportGeneration: number
|
||||
diagnostics: RemoteRuntimeSharedConnectionDiagnostics
|
||||
}) => void
|
||||
) => () => void
|
||||
prepareBrowserClientHostPlacement: (
|
||||
args: BrowserClientHostPlacementPreparationRequest
|
||||
) => Promise<BrowserPageCreationPlacement>
|
||||
|
||||
@@ -197,6 +197,8 @@ import type {
|
||||
RuntimeTerminalPresentation
|
||||
} from '../shared/runtime-types'
|
||||
import type { RuntimeRpcResponse } from '../shared/runtime-rpc-envelope'
|
||||
import type { RemoteRuntimeSharedConnectionDiagnostics } from '../shared/remote-runtime-shared-control-types'
|
||||
import { RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL } from '../shared/runtime-environment-diagnostics'
|
||||
import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments'
|
||||
import type { RemoteWorkspaceChangedEvent } from '../shared/remote-workspace-types'
|
||||
import type {
|
||||
@@ -4784,6 +4786,24 @@ const api = {
|
||||
ipcRenderer.invoke('runtimeEnvironments:getStatus', args),
|
||||
retryControlConnection: (args: { selector: string }): Promise<void> =>
|
||||
ipcRenderer.invoke('runtimeEnvironments:retryControlConnection', args),
|
||||
onSharedControlDiagnostics: (
|
||||
callback: (event: {
|
||||
environmentId: string
|
||||
transportGeneration: number
|
||||
diagnostics: RemoteRuntimeSharedConnectionDiagnostics
|
||||
}) => void
|
||||
): (() => void) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
data: {
|
||||
environmentId: string
|
||||
transportGeneration: number
|
||||
diagnostics: RemoteRuntimeSharedConnectionDiagnostics
|
||||
}
|
||||
): void => callback(data)
|
||||
ipcRenderer.on(RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL, listener)
|
||||
return () => ipcRenderer.removeListener(RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL, listener)
|
||||
},
|
||||
prepareBrowserClientHostPlacement: (args) =>
|
||||
ipcRenderer.invoke('runtimeEnvironments:prepareBrowserClientHostPlacement', args),
|
||||
retryConnectionsNow: (): Promise<void> =>
|
||||
|
||||
@@ -203,6 +203,36 @@ describe('RuntimeEnvironmentsPane host details', () => {
|
||||
).toBe('disconnected')
|
||||
})
|
||||
|
||||
it.each(['closed', 'reconnecting'] as const)(
|
||||
'does not keep a ready details cache green when shared control is %s',
|
||||
(state) => {
|
||||
expect(
|
||||
getRuntimeServerConnectionState(
|
||||
details({
|
||||
status: 'ready',
|
||||
runtimeStatus: {
|
||||
runtimeId: 'runtime-live',
|
||||
rendererGraphEpoch: 1,
|
||||
graphStatus: 'ready',
|
||||
authoritativeWindowId: 1,
|
||||
liveTabCount: 0,
|
||||
liveLeafCount: 0,
|
||||
remoteControl: {
|
||||
state,
|
||||
pendingRequestCount: 0,
|
||||
subscriptionCount: 1,
|
||||
reconnectAttempt: 1,
|
||||
lastConnectedAt: 1,
|
||||
lastClose: null,
|
||||
lastError: null
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
).not.toBe('connected')
|
||||
}
|
||||
)
|
||||
|
||||
it('explains that selecting a saved server is the explicit default Host mode', () => {
|
||||
expect(getActiveServerModeDescription(true)).toContain('Use this computer by default')
|
||||
expect(getActiveServerModeDescription(true)).toContain('browser/mobile handoff')
|
||||
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY
|
||||
} from '../../../../shared/protocol-version'
|
||||
import type { RuntimeStatus } from '../../../../shared/runtime-types'
|
||||
import {
|
||||
runtimeHostConnectionState,
|
||||
type RuntimeHostConnectionState
|
||||
} from '../../../../shared/runtime-host-connection-state'
|
||||
|
||||
export type RuntimeHostDetails = {
|
||||
status: 'loading' | 'ready' | 'error'
|
||||
@@ -147,7 +151,7 @@ export function isRuntimeEnvironmentRemovalBlocked(
|
||||
return activeRuntimeEnvironmentId === environmentId
|
||||
}
|
||||
|
||||
export type RuntimeServerConnectionState = 'connected' | 'checking' | 'disconnected'
|
||||
export type RuntimeServerConnectionState = RuntimeHostConnectionState
|
||||
|
||||
export function getRuntimeServerConnectionState(
|
||||
details: RuntimeHostDetails | undefined
|
||||
@@ -158,11 +162,11 @@ export function getRuntimeServerConnectionState(
|
||||
if (details.status !== 'ready' || details.compatibility?.kind === 'blocked') {
|
||||
return 'disconnected'
|
||||
}
|
||||
// Why: an attached, reachable, compatible host is "Connected" (and exposes
|
||||
// Disconnect). Whether it is the default *active* server is a separate concept,
|
||||
// surfaced by the Advanced > Active Server selector and the row's help text —
|
||||
// it must not change this connection label, or the dot/label/button disagree.
|
||||
return 'connected'
|
||||
// Older clients can report a ready details phase without embedding RuntimeStatus.
|
||||
if (details.runtimeStatus === null) {
|
||||
return 'connected'
|
||||
}
|
||||
return runtimeHostConnectionState({ hasStatusEntry: true, status: details.runtimeStatus })
|
||||
}
|
||||
|
||||
export function getRuntimeServerConnectionLabel(state: RuntimeServerConnectionState): string {
|
||||
@@ -172,11 +176,21 @@ export function getRuntimeServerConnectionLabel(state: RuntimeServerConnectionSt
|
||||
'auto.components.settings.RuntimeEnvironmentsPane.serverConnected',
|
||||
'Connected'
|
||||
)
|
||||
case 'workspace-window-closed':
|
||||
return translate(
|
||||
'auto.components.settings.RuntimeEnvironmentsPane.serverWorkspaceWindowClosed',
|
||||
'Workspace window closed'
|
||||
)
|
||||
case 'checking':
|
||||
return translate(
|
||||
'auto.components.settings.RuntimeEnvironmentsPane.serverChecking',
|
||||
'Checking…'
|
||||
)
|
||||
case 'reconnecting':
|
||||
return translate(
|
||||
'auto.components.settings.RuntimeEnvironmentsPane.serverReconnecting',
|
||||
'Reconnecting'
|
||||
)
|
||||
case 'disconnected':
|
||||
return translate(
|
||||
'auto.components.settings.RuntimeEnvironmentsPane.serverDisconnected',
|
||||
@@ -190,6 +204,8 @@ export function getRuntimeServerDotClass(state: RuntimeServerConnectionState): s
|
||||
case 'connected':
|
||||
return 'bg-emerald-500'
|
||||
case 'checking':
|
||||
case 'workspace-window-closed':
|
||||
case 'reconnecting':
|
||||
return 'bg-yellow-500'
|
||||
case 'disconnected':
|
||||
return 'bg-muted-foreground/40'
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-e
|
||||
import type { RemoteServerUpdateEntry } from '@/runtime/remote-server-update-coordinator'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAppStore } from '@/store'
|
||||
import { Button } from '../ui/button'
|
||||
import {
|
||||
getHostDetailsDescription,
|
||||
@@ -51,9 +52,27 @@ export function RuntimeServerRow({
|
||||
onRemove
|
||||
}: RuntimeServerRowProps): React.JSX.Element {
|
||||
const detailsDescription = getHostDetailsDescription(details)
|
||||
const connectionState = getRuntimeServerConnectionState(details)
|
||||
const runtimeStatusEntry = useAppStore((state) =>
|
||||
state.runtimeStatusByEnvironmentId.get(environment.id)
|
||||
)
|
||||
const connectionState =
|
||||
details?.status === 'loading' && !runtimeStatusEntry?.status
|
||||
? 'checking'
|
||||
: runtimeStatusEntry
|
||||
? getRuntimeServerConnectionState({
|
||||
...(details ?? {
|
||||
status: runtimeStatusEntry.status ? 'ready' : 'error',
|
||||
runtimeStatus: null,
|
||||
compatibility: null,
|
||||
error: null
|
||||
}),
|
||||
status: runtimeStatusEntry.status ? 'ready' : 'error',
|
||||
runtimeStatus: runtimeStatusEntry.status
|
||||
})
|
||||
: getRuntimeServerConnectionState(details)
|
||||
// A connected host exposes Disconnect; otherwise Connect.
|
||||
const isReachable = connectionState === 'connected'
|
||||
const isReachable =
|
||||
connectionState === 'connected' || connectionState === 'workspace-window-closed'
|
||||
const actionBusy = connecting || switching || disconnecting || removing
|
||||
|
||||
return (
|
||||
|
||||
@@ -60,6 +60,14 @@ export function installAppLifetimeIpcEvents(
|
||||
)
|
||||
|
||||
const worktreeRuntime = createWorktreeEventRuntime(unsubs, isRuntimeEnvironmentActive)
|
||||
const onSharedControlDiagnostics = window.api.runtimeEnvironments?.onSharedControlDiagnostics
|
||||
if (onSharedControlDiagnostics) {
|
||||
unsubs.push(
|
||||
onSharedControlDiagnostics((event) => {
|
||||
useAppStore.getState().publishRuntimeEnvironmentDiagnostics(event)
|
||||
})
|
||||
)
|
||||
}
|
||||
const unsubscribeRuntimeEnvironmentStore = registerRuntimeClientIpcBridge(unsubs, worktreeRuntime)
|
||||
registerProjectCatalogIpcBridge(
|
||||
unsubs,
|
||||
|
||||
@@ -28,6 +28,7 @@ const EXPECTED_DIRECT_CALLBACK_METHODS = [
|
||||
'runtime.onNativeChatLaunchDraftResolved',
|
||||
'runtime.onTerminalDriverChanged',
|
||||
'runtime.onTerminalFitOverrideChanged',
|
||||
'runtimeEnvironments.onSharedControlDiagnostics',
|
||||
'settings.onChanged',
|
||||
'ssh.onCredentialRequest',
|
||||
'ssh.onCredentialResolved',
|
||||
@@ -102,6 +103,7 @@ const EXPECTED_DIRECT_CALLBACK_METHODS = [
|
||||
const EXPECTED_CALLBACK_REGISTRATION_SEQUENCE = [
|
||||
'ui.onMobileMarkdownRequest',
|
||||
'automations.onChanged',
|
||||
'runtimeEnvironments.onSharedControlDiagnostics',
|
||||
'repos.onChanged',
|
||||
'worktrees.onChanged',
|
||||
'worktrees.onHeadIdentitiesChanged',
|
||||
@@ -374,8 +376,9 @@ describe('useIpcEvents App-lifetime lifecycle', () => {
|
||||
).toEqual([
|
||||
'ui.onMobileMarkdownRequest',
|
||||
'automations.onChanged',
|
||||
'runtimeEnvironments.onSharedControlDiagnostics',
|
||||
'runtimeEnvironments.subscribe',
|
||||
...EXPECTED_CALLBACK_REGISTRATION_SEQUENCE.slice(2)
|
||||
...EXPECTED_CALLBACK_REGISTRATION_SEQUENCE.slice(3)
|
||||
])
|
||||
const groupOrder = (names: readonly string[]): string[] =>
|
||||
registrationOrder.filter((entry) => names.includes(entry))
|
||||
|
||||
@@ -7926,7 +7926,9 @@
|
||||
"3f67e8078a": "Use this computer by default. Choose a saved server only when you want supported projects, files, terminals, provider checks, and browser/mobile handoff to run through that server.",
|
||||
"2c85efb3e8": "Selecting a saved server makes this browser use that paired Orca runtime as its default Host.",
|
||||
"serverConnected": "Connected",
|
||||
"serverWorkspaceWindowClosed": "Workspace window closed",
|
||||
"serverChecking": "Checking…",
|
||||
"serverReconnecting": "Reconnecting",
|
||||
"serverDisconnected": "Disconnected",
|
||||
"disconnectedServer": "Disconnected from {{value0}}.",
|
||||
"connectToRemoteServers": "Connect to remote servers",
|
||||
|
||||
@@ -1,72 +1,7 @@
|
||||
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'
|
||||
}
|
||||
// Why no lastError requirement: a clean close (server restart, host sleep, network
|
||||
// blip) leaves lastError null, and demanding an error string painted those hosts green.
|
||||
if (remoteControl?.state === 'closed') {
|
||||
return 'disconnected'
|
||||
}
|
||||
// Why: the socket is up but ready/auth has not completed, so nothing can run there yet.
|
||||
if (remoteControl && remoteControl.state !== 'ready') {
|
||||
return 'checking'
|
||||
}
|
||||
// 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'
|
||||
}
|
||||
export {
|
||||
isConnectedRuntimeHostState,
|
||||
runtimeHostConnectionState,
|
||||
runtimeStatusForOverall,
|
||||
type HostStatus,
|
||||
type RuntimeHostConnectionState
|
||||
} from '../../../shared/runtime-host-connection-state'
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
const connectionGenerationByEnvironment = new Map<string, number>()
|
||||
|
||||
export function getRuntimeEnvironmentConnectionGeneration(environmentId: string): number {
|
||||
return connectionGenerationByEnvironment.get(environmentId) ?? 0
|
||||
}
|
||||
|
||||
export function setRuntimeEnvironmentConnectionGenerationForTests(
|
||||
environmentId: string,
|
||||
generation: number
|
||||
): void {
|
||||
connectionGenerationByEnvironment.set(environmentId, generation)
|
||||
}
|
||||
|
||||
export function advanceRuntimeEnvironmentConnectionGeneration(environmentId: string): number {
|
||||
const next = getRuntimeEnvironmentConnectionGeneration(environmentId) + 1
|
||||
connectionGenerationByEnvironment.set(environmentId, next)
|
||||
return next
|
||||
}
|
||||
|
||||
export function clearRuntimeEnvironmentConnectionGenerations(): Iterable<string> {
|
||||
const environmentIds = [...connectionGenerationByEnvironment.keys()]
|
||||
connectionGenerationByEnvironment.clear()
|
||||
return environmentIds
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../../../shared/protocol-version'
|
||||
import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../../../shared/remote-runtime-shared-control-types'
|
||||
import type { RuntimeEnvironmentStatus } from './runtime-status'
|
||||
|
||||
const diagnosticsGenerationByEnvironment = new Map<string, number>()
|
||||
|
||||
export function updateRuntimeEnvironmentStatusOverlay(
|
||||
state: Map<string, RuntimeEnvironmentStatus>,
|
||||
environmentId: string,
|
||||
status: RuntimeEnvironmentStatus
|
||||
): Map<string, RuntimeEnvironmentStatus> {
|
||||
const current = state.get(environmentId)
|
||||
if (!current || current.status?.runtimeId !== status.status?.runtimeId) {
|
||||
return state
|
||||
}
|
||||
return new Map(state).set(environmentId, status)
|
||||
}
|
||||
|
||||
export function acceptRuntimeEnvironmentDiagnosticsGeneration(
|
||||
environmentId: string,
|
||||
transportGeneration: number
|
||||
): boolean {
|
||||
const previous = diagnosticsGenerationByEnvironment.get(environmentId)
|
||||
if (previous !== undefined && transportGeneration < previous) {
|
||||
return false
|
||||
}
|
||||
diagnosticsGenerationByEnvironment.set(environmentId, transportGeneration)
|
||||
return true
|
||||
}
|
||||
|
||||
export function clearRuntimeEnvironmentDiagnosticsGenerationsForTests(): void {
|
||||
diagnosticsGenerationByEnvironment.clear()
|
||||
}
|
||||
|
||||
export function mergePushedRuntimeEnvironmentDiagnostics(args: {
|
||||
environmentId: string
|
||||
transportGeneration: number
|
||||
diagnostics: RemoteRuntimeSharedConnectionDiagnostics
|
||||
current: RuntimeEnvironmentStatus | undefined
|
||||
publish: (status: RuntimeEnvironmentStatus) => void
|
||||
}): void {
|
||||
if (
|
||||
!args.current?.status?.capabilities?.includes(REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY) ||
|
||||
!acceptRuntimeEnvironmentDiagnosticsGeneration(args.environmentId, args.transportGeneration)
|
||||
) {
|
||||
return
|
||||
}
|
||||
args.publish({
|
||||
...args.current,
|
||||
status: { ...args.current.status, remoteControl: args.diagnostics }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../../../shared/remote-runtime-shared-control-types'
|
||||
import type { AppState } from '../types'
|
||||
import type { RuntimeEnvironmentStatus } from './runtime-status'
|
||||
import * as diagnosticsGeneration from './runtime-status-diagnostics-generation'
|
||||
|
||||
export function updateRuntimeStatusStore(
|
||||
state: AppState,
|
||||
updater: (state: Map<string, RuntimeEnvironmentStatus>) => Map<string, RuntimeEnvironmentStatus>
|
||||
): AppState | Pick<AppState, 'runtimeStatusByEnvironmentId'> {
|
||||
const next = updater(state.runtimeStatusByEnvironmentId)
|
||||
return next === state.runtimeStatusByEnvironmentId
|
||||
? state
|
||||
: { runtimeStatusByEnvironmentId: next }
|
||||
}
|
||||
|
||||
export function publishRuntimeEnvironmentDiagnostics(args: {
|
||||
environmentId: string
|
||||
transportGeneration: number
|
||||
diagnostics: RemoteRuntimeSharedConnectionDiagnostics
|
||||
getCurrent: () => RuntimeEnvironmentStatus | undefined
|
||||
updateState: (status: RuntimeEnvironmentStatus) => boolean
|
||||
afterPublish?: (status: RuntimeEnvironmentStatus) => void
|
||||
}): void {
|
||||
diagnosticsGeneration.mergePushedRuntimeEnvironmentDiagnostics({
|
||||
environmentId: args.environmentId,
|
||||
transportGeneration: args.transportGeneration,
|
||||
diagnostics: args.diagnostics,
|
||||
current: args.getCurrent(),
|
||||
publish: (status) => {
|
||||
if (args.updateState(status)) {
|
||||
args.afterPublish?.(status)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function applyRuntimeEnvironmentStatusOverlay(args: {
|
||||
environmentId: string
|
||||
status: RuntimeEnvironmentStatus
|
||||
setState: (
|
||||
updater: (state: Map<string, RuntimeEnvironmentStatus>) => Map<string, RuntimeEnvironmentStatus>
|
||||
) => void
|
||||
}): boolean {
|
||||
let updated = false
|
||||
args.setState((state) => {
|
||||
const next = diagnosticsGeneration.updateRuntimeEnvironmentStatusOverlay(
|
||||
state,
|
||||
args.environmentId,
|
||||
args.status
|
||||
)
|
||||
updated = next !== state
|
||||
return next
|
||||
})
|
||||
return updated
|
||||
}
|
||||
|
||||
export function createRuntimeEnvironmentDiagnosticsPublisher(args: {
|
||||
getCurrent: (environmentId: string) => RuntimeEnvironmentStatus | undefined
|
||||
setState: (
|
||||
updater: (state: Map<string, RuntimeEnvironmentStatus>) => Map<string, RuntimeEnvironmentStatus>
|
||||
) => void
|
||||
afterPublish: (environmentId: string, status: RuntimeEnvironmentStatus) => void
|
||||
}): (event: {
|
||||
environmentId: string
|
||||
transportGeneration: number
|
||||
diagnostics: RemoteRuntimeSharedConnectionDiagnostics
|
||||
}) => void {
|
||||
return (event) =>
|
||||
publishRuntimeEnvironmentDiagnostics({
|
||||
...event,
|
||||
getCurrent: () => args.getCurrent(event.environmentId),
|
||||
updateState: (status) =>
|
||||
applyRuntimeEnvironmentStatusOverlay({
|
||||
environmentId: event.environmentId,
|
||||
status,
|
||||
setState: args.setState
|
||||
}),
|
||||
afterPublish: (status) => args.afterPublish(event.environmentId, status)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { create } from 'zustand'
|
||||
import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../../../shared/protocol-version'
|
||||
import type { RuntimeStatus } from '../../../../shared/runtime-types'
|
||||
import { createRuntimeStatusSlice, type RuntimeStatusSlice } from './runtime-status'
|
||||
|
||||
function makeStatus(overrides: Partial<RuntimeStatus> = {}): RuntimeStatus {
|
||||
return {
|
||||
runtimeId: 'runtime-a',
|
||||
rendererGraphEpoch: 0,
|
||||
graphStatus: 'ready',
|
||||
authoritativeWindowId: null,
|
||||
liveTabCount: 3,
|
||||
liveLeafCount: 0,
|
||||
runtimeProtocolVersion: 3,
|
||||
minCompatibleRuntimeClientVersion: 3,
|
||||
capabilities: ['browser.screencast.v1'],
|
||||
...overrides
|
||||
} as RuntimeStatus
|
||||
}
|
||||
|
||||
function createSliceStore() {
|
||||
return create<RuntimeStatusSlice>()((...a) => ({
|
||||
...createRuntimeStatusSlice(...(a as unknown as Parameters<typeof createRuntimeStatusSlice>))
|
||||
}))
|
||||
}
|
||||
|
||||
describe('runtime-status diagnostics', () => {
|
||||
it('merges transport diagnostics into the complete status and fences stale pushes', () => {
|
||||
const store = createSliceStore()
|
||||
const status = makeStatus({
|
||||
capabilities: ['browser.screencast.v1', REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY]
|
||||
})
|
||||
store.getState().setRuntimeEnvironmentStatus('env-a', { status, checkedAt: 1 })
|
||||
const closed = {
|
||||
state: 'closed' as const,
|
||||
pendingRequestCount: 0,
|
||||
subscriptionCount: 1,
|
||||
reconnectAttempt: 2,
|
||||
lastConnectedAt: 1,
|
||||
lastClose: { code: 1006, reason: 'network' },
|
||||
lastError: 'connection lost'
|
||||
}
|
||||
store.getState().publishRuntimeEnvironmentDiagnostics({
|
||||
environmentId: 'env-a',
|
||||
transportGeneration: 3,
|
||||
diagnostics: closed
|
||||
})
|
||||
expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status).toMatchObject({
|
||||
runtimeId: 'runtime-a',
|
||||
capabilities: expect.arrayContaining([
|
||||
'browser.screencast.v1',
|
||||
REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY
|
||||
]),
|
||||
liveTabCount: 3,
|
||||
remoteControl: closed
|
||||
})
|
||||
store.getState().publishRuntimeEnvironmentDiagnostics({
|
||||
environmentId: 'env-a',
|
||||
transportGeneration: 2,
|
||||
diagnostics: { ...closed, state: 'ready' }
|
||||
})
|
||||
expect(
|
||||
store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status?.remoteControl?.state
|
||||
).toBe('closed')
|
||||
})
|
||||
|
||||
it('ignores diagnostics after the latest status drops shared-control support', () => {
|
||||
const store = createSliceStore()
|
||||
const status = makeStatus({ capabilities: [] })
|
||||
store.getState().setRuntimeEnvironmentStatus('env-a', { status, checkedAt: 1 })
|
||||
|
||||
store.getState().publishRuntimeEnvironmentDiagnostics({
|
||||
environmentId: 'env-a',
|
||||
transportGeneration: 3,
|
||||
diagnostics: {
|
||||
state: 'reconnecting',
|
||||
pendingRequestCount: 0,
|
||||
subscriptionCount: 1,
|
||||
reconnectAttempt: 2,
|
||||
lastConnectedAt: 1,
|
||||
lastClose: { code: 1006, reason: 'network' },
|
||||
lastError: 'connection lost'
|
||||
}
|
||||
})
|
||||
|
||||
expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status).toBe(status)
|
||||
})
|
||||
})
|
||||
@@ -15,6 +15,14 @@ type RecheckState = {
|
||||
publish: (status: RuntimeStatus | null) => void
|
||||
}
|
||||
|
||||
type RuntimeStatusStore = {
|
||||
runtimeEnvironments: readonly { id: string }[]
|
||||
setRuntimeEnvironmentStatus: (
|
||||
environmentId: string,
|
||||
status: { status: RuntimeStatus | null; checkedAt: number }
|
||||
) => void
|
||||
}
|
||||
|
||||
const rechecks = new Map<string, RecheckState>()
|
||||
|
||||
export function reconcileRuntimeStatusRecheck(args: {
|
||||
@@ -55,6 +63,27 @@ export function reconcileRuntimeStatusRecheck(args: {
|
||||
armRuntimeStatusRecheck(args.environmentId, state)
|
||||
}
|
||||
|
||||
export function reconcileRuntimeStatusForSlice(
|
||||
environmentId: string,
|
||||
status: RuntimeStatus | null,
|
||||
get: () => RuntimeStatusStore,
|
||||
getConnectionGeneration: () => number
|
||||
): void {
|
||||
reconcileRuntimeStatusRecheck({
|
||||
environmentId,
|
||||
status,
|
||||
connectionGeneration: getConnectionGeneration(),
|
||||
environmentExists: () =>
|
||||
get().runtimeEnvironments.some((environment) => environment.id === environmentId),
|
||||
getConnectionGeneration,
|
||||
publish: (nextStatus) =>
|
||||
get().setRuntimeEnvironmentStatus(environmentId, {
|
||||
status: nextStatus,
|
||||
checkedAt: Date.now()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function cancelRuntimeStatusRecheck(environmentId: string): void {
|
||||
const state = rechecks.get(environmentId)
|
||||
if (!state) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { StateCreator } from 'zustand'
|
||||
import type { AppState } from '../types'
|
||||
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
|
||||
import type { RuntimeStatus } from '../../../../shared/runtime-types'
|
||||
import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../../../shared/remote-runtime-shared-control-types'
|
||||
import { runtimeEnvironmentStatusesEqual } from './runtime-environment-status-equality'
|
||||
import {
|
||||
clearRecentRuntimeCompatibilityFailure,
|
||||
@@ -16,13 +17,19 @@ import {
|
||||
import { reconcileCatalogRows } from './repo-identity-reconcile'
|
||||
import { createRuntimeStatusHydration } from './runtime-status-hydration'
|
||||
import { refreshRuntimeEnvironmentStatus } from './runtime-status-refresh'
|
||||
import * as runtimeStatusDiagnostics from './runtime-status-diagnostics-generation'
|
||||
import * as runtimeStatusDiagnosticsPublish from './runtime-status-diagnostics-publish'
|
||||
import {
|
||||
advanceRuntimeEnvironmentConnectionGeneration,
|
||||
clearRuntimeEnvironmentConnectionGenerations,
|
||||
getRuntimeEnvironmentConnectionGeneration
|
||||
} from './runtime-status-connection-generation'
|
||||
import { replayClientHostedBrowserCloseIntents } from '@/runtime/client-hosted-browser-close-intent-replay'
|
||||
import {
|
||||
ensureBrowserClientHostForRestartedRuntime,
|
||||
ensureBrowserClientHostsForRestoredPages
|
||||
} from '@/runtime/restored-client-hosted-browser-host-attach'
|
||||
import * as runtimeStatusRecheck from './runtime-status-recheck'
|
||||
|
||||
/** Live status for one saved runtime environment, as last observed by the
|
||||
* renderer. `status === null` records a probe that failed or timed out so the
|
||||
* sidebar can still distinguish "unknown/unreachable" from "never checked". */
|
||||
@@ -75,6 +82,12 @@ export type RuntimeStatusSlice = {
|
||||
status: RuntimeEnvironmentStatus,
|
||||
options?: { suppressDisconnectToast?: boolean }
|
||||
) => void
|
||||
/** Merges main-owned transport diagnostics into a complete runtime status snapshot. */
|
||||
publishRuntimeEnvironmentDiagnostics: (args: {
|
||||
environmentId: string
|
||||
transportGeneration: number
|
||||
diagnostics: RemoteRuntimeSharedConnectionDiagnostics
|
||||
}) => void
|
||||
/** Drops a removed environment so stale hosts don't linger in the registry. */
|
||||
clearRuntimeEnvironmentStatus: (environmentId: string) => void
|
||||
/** Drops every entry whose id is not in the saved-environments set. */
|
||||
@@ -92,28 +105,14 @@ export type RuntimeStatusSlice = {
|
||||
hydrateRuntimeEnvironmentStatuses: () => Promise<void>
|
||||
}
|
||||
|
||||
const connectionGenerationByEnvironment = new Map<string, number>()
|
||||
|
||||
export function getRuntimeEnvironmentConnectionGeneration(environmentId: string): number {
|
||||
return connectionGenerationByEnvironment.get(environmentId) ?? 0
|
||||
}
|
||||
export {
|
||||
getRuntimeEnvironmentConnectionGeneration,
|
||||
setRuntimeEnvironmentConnectionGenerationForTests
|
||||
} from './runtime-status-connection-generation'
|
||||
|
||||
export const clearRuntimeEnvironmentConnectionGenerationsForTests = (): void => {
|
||||
runtimeStatusRecheck.cancelRuntimeStatusRechecks(connectionGenerationByEnvironment.keys())
|
||||
connectionGenerationByEnvironment.clear()
|
||||
}
|
||||
|
||||
export const setRuntimeEnvironmentConnectionGenerationForTests = (
|
||||
environmentId: string,
|
||||
generation: number
|
||||
): void => {
|
||||
connectionGenerationByEnvironment.set(environmentId, generation)
|
||||
}
|
||||
|
||||
function advanceRuntimeEnvironmentConnectionGeneration(environmentId: string): number {
|
||||
const next = getRuntimeEnvironmentConnectionGeneration(environmentId) + 1
|
||||
connectionGenerationByEnvironment.set(environmentId, next)
|
||||
return next
|
||||
runtimeStatusRecheck.cancelRuntimeStatusRechecks(clearRuntimeEnvironmentConnectionGenerations())
|
||||
runtimeStatusDiagnostics.clearRuntimeEnvironmentDiagnosticsGenerationsForTests()
|
||||
}
|
||||
|
||||
export const createRuntimeStatusSlice: StateCreator<AppState, [], [], RuntimeStatusSlice> = (
|
||||
@@ -287,19 +286,9 @@ export const createRuntimeStatusSlice: StateCreator<AppState, [], [], RuntimeSta
|
||||
...(environmentsChanged ? { runtimeEnvironments } : {})
|
||||
}
|
||||
})
|
||||
runtimeStatusRecheck.reconcileRuntimeStatusRecheck({
|
||||
environmentId,
|
||||
status: status.status,
|
||||
connectionGeneration: getRuntimeEnvironmentConnectionGeneration(environmentId),
|
||||
environmentExists: () =>
|
||||
get().runtimeEnvironments.some((environment) => environment.id === environmentId),
|
||||
getConnectionGeneration: () => getRuntimeEnvironmentConnectionGeneration(environmentId),
|
||||
publish: (nextStatus) =>
|
||||
get().setRuntimeEnvironmentStatus(environmentId, {
|
||||
status: nextStatus,
|
||||
checkedAt: Date.now()
|
||||
})
|
||||
})
|
||||
runtimeStatusRecheck.reconcileRuntimeStatusForSlice(environmentId, status.status, get, () =>
|
||||
getRuntimeEnvironmentConnectionGeneration(environmentId)
|
||||
)
|
||||
if (runtimeRestarted) {
|
||||
void ensureBrowserClientHostForRestartedRuntime(get(), environmentId)
|
||||
}
|
||||
@@ -312,6 +301,17 @@ export const createRuntimeStatusSlice: StateCreator<AppState, [], [], RuntimeSta
|
||||
}
|
||||
},
|
||||
|
||||
publishRuntimeEnvironmentDiagnostics:
|
||||
runtimeStatusDiagnosticsPublish.createRuntimeEnvironmentDiagnosticsPublisher({
|
||||
getCurrent: (environmentId) => get().runtimeStatusByEnvironmentId.get(environmentId),
|
||||
setState: (updater) =>
|
||||
set((s) => runtimeStatusDiagnosticsPublish.updateRuntimeStatusStore(s, updater)),
|
||||
afterPublish: (environmentId, status) =>
|
||||
runtimeStatusRecheck.reconcileRuntimeStatusForSlice(environmentId, status.status, get, () =>
|
||||
getRuntimeEnvironmentConnectionGeneration(environmentId)
|
||||
)
|
||||
}),
|
||||
|
||||
clearRuntimeEnvironmentStatus: (environmentId) => {
|
||||
runtimeStatusRecheck.cancelRuntimeStatusRecheck(environmentId)
|
||||
dismissRuntimeDisconnectedToast(environmentId)
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import * as sharedControlProtocol from './remote-runtime-shared-control-protocol'
|
||||
import * as sharedControlState from './remote-runtime-shared-control-state'
|
||||
import { closeSharedControlConnectionSubscription } from './remote-runtime-shared-control-subscription-close'
|
||||
import * as sharedControlSubscriptions from './remote-runtime-shared-control-subscriptions'
|
||||
import * as sharedControlSend from './remote-runtime-shared-control-send'
|
||||
import type {
|
||||
SharedControlLogicalSubscription,
|
||||
SharedControlPendingRequest
|
||||
} from './remote-runtime-shared-control-types'
|
||||
import type { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids'
|
||||
|
||||
export function sendSharedControlRequest(args: {
|
||||
pendingRequests: Map<string, SharedControlPendingRequest<unknown>>
|
||||
requestId: string
|
||||
state: Parameters<typeof sharedControlProtocol.sendSharedControlEncryptedSerialized>[0]['state']
|
||||
ws: Parameters<typeof sharedControlProtocol.sendSharedControlEncryptedSerialized>[0]['ws']
|
||||
sharedKey: Parameters<
|
||||
typeof sharedControlProtocol.sendSharedControlEncryptedSerialized
|
||||
>[0]['sharedKey']
|
||||
}): void {
|
||||
sharedControlSend.sendSharedControlRequest({
|
||||
pendingRequests: args.pendingRequests,
|
||||
requestId: args.requestId,
|
||||
send: (serialized) =>
|
||||
sharedControlProtocol.sendSharedControlEncryptedSerialized({
|
||||
state: args.state,
|
||||
ws: args.ws,
|
||||
sharedKey: args.sharedKey,
|
||||
serialized
|
||||
}),
|
||||
reject: (id, error) =>
|
||||
sharedControlState.rejectSharedControlPendingRequest(args.pendingRequests, id, error)
|
||||
})
|
||||
}
|
||||
|
||||
export function sendSharedControlSubscription(args: {
|
||||
subscriptions: Map<string, SharedControlLogicalSubscription<unknown>>
|
||||
subscription: SharedControlLogicalSubscription<unknown>
|
||||
deviceToken: string
|
||||
send: (payload: unknown) => boolean
|
||||
}): void {
|
||||
sharedControlSend.sendSharedControlSubscription(args)
|
||||
}
|
||||
|
||||
export function replaySharedControlSubscriptions(args: {
|
||||
subscriptions: Map<string, SharedControlLogicalSubscription<unknown>>
|
||||
send: (subscription: SharedControlLogicalSubscription<unknown>) => void
|
||||
tagReplayedResponses: boolean
|
||||
}): boolean {
|
||||
sharedControlSubscriptions.replaySharedControlSubscriptions(args)
|
||||
return true
|
||||
}
|
||||
|
||||
export function replayRuntimeControlSubscriptions(args: {
|
||||
subscriptions: Map<string, SharedControlLogicalSubscription<unknown>>
|
||||
deviceToken: string
|
||||
send: (payload: unknown) => boolean
|
||||
tagReplayedResponses: boolean
|
||||
}): boolean {
|
||||
return replaySharedControlSubscriptions({
|
||||
subscriptions: args.subscriptions,
|
||||
send: (subscription) =>
|
||||
sendSharedControlSubscription({
|
||||
subscriptions: args.subscriptions,
|
||||
subscription,
|
||||
deviceToken: args.deviceToken,
|
||||
send: args.send
|
||||
}),
|
||||
tagReplayedResponses: args.tagReplayedResponses
|
||||
})
|
||||
}
|
||||
|
||||
export function closeSharedControlSubscription(args: {
|
||||
subscriptions: Map<string, SharedControlLogicalSubscription<unknown>>
|
||||
retiredRequestIds: SharedControlRetiredRequestIds
|
||||
requestId: string
|
||||
deviceToken: string
|
||||
send: (payload: unknown) => boolean
|
||||
}): void {
|
||||
closeSharedControlConnectionSubscription(args)
|
||||
}
|
||||
|
||||
export function closeRuntimeControlSubscription(args: {
|
||||
subscriptions: Map<string, SharedControlLogicalSubscription<unknown>>
|
||||
retiredRequestIds: SharedControlRetiredRequestIds
|
||||
requestId: string
|
||||
deviceToken: string
|
||||
send: (payload: unknown) => boolean
|
||||
clearWhenIdle: (isIdle: boolean) => void
|
||||
}): void {
|
||||
closeSharedControlSubscription(args)
|
||||
args.clearWhenIdle(args.subscriptions.size === 0)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { handleSharedControlTextFrame } from './remote-runtime-shared-control-frame-handler'
|
||||
import type { RemoteRuntimeClientError } from './remote-runtime-client-error'
|
||||
import type { RuntimeCapability } from './protocol-version'
|
||||
import type {
|
||||
SharedControlConnectionState,
|
||||
SharedControlLogicalSubscription,
|
||||
SharedControlPendingRequest,
|
||||
SharedControlReadyWaiter
|
||||
} from './remote-runtime-shared-control-types'
|
||||
import type { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids'
|
||||
|
||||
export function handleRuntimeControlTextFrame(args: {
|
||||
frame: string
|
||||
socketGeneration: number
|
||||
isCurrent: (generation: number) => boolean
|
||||
getState: () => SharedControlConnectionState
|
||||
getSharedKey: () => Uint8Array | null
|
||||
environmentId?: string
|
||||
deviceToken: string
|
||||
clientCapabilities: readonly RuntimeCapability[]
|
||||
pendingRequests: Map<string, SharedControlPendingRequest<unknown>>
|
||||
subscriptions: Map<string, SharedControlLogicalSubscription<unknown>>
|
||||
retiredRequestIds: SharedControlRetiredRequestIds
|
||||
readyWaiters: SharedControlReadyWaiter[]
|
||||
setState: (state: SharedControlConnectionState) => void
|
||||
handleSocketClosed: (error: RemoteRuntimeClientError) => void
|
||||
sendEncrypted: (payload: unknown) => boolean
|
||||
markReady: () => void
|
||||
replaySubscriptions: () => void
|
||||
publishDiagnostics: () => void
|
||||
}): void {
|
||||
if (!args.isCurrent(args.socketGeneration)) {
|
||||
return
|
||||
}
|
||||
handleSharedControlTextFrame({
|
||||
frame: args.frame,
|
||||
state: args.getState(),
|
||||
sharedKey: args.getSharedKey(),
|
||||
environmentId: args.environmentId,
|
||||
deviceToken: args.deviceToken,
|
||||
clientCapabilities: args.clientCapabilities,
|
||||
pendingRequests: args.pendingRequests,
|
||||
subscriptions: args.subscriptions,
|
||||
retiredRequestIds: args.retiredRequestIds,
|
||||
readyWaiters: args.readyWaiters,
|
||||
setState: (state) => {
|
||||
args.setState(state)
|
||||
args.publishDiagnostics()
|
||||
},
|
||||
handleSocketClosed: args.handleSocketClosed,
|
||||
sendEncrypted: args.sendEncrypted,
|
||||
markReady: args.markReady,
|
||||
replaySubscriptions: args.replaySubscriptions
|
||||
})
|
||||
}
|
||||
@@ -22,7 +22,10 @@ afterEach(closeSharedControlTestServers)
|
||||
describe('RemoteRuntimeSharedControlConnection', () => {
|
||||
it('routes multiple one-shot RPCs over one authenticated WebSocket', async () => {
|
||||
const server = await createServer()
|
||||
const connection = new RemoteRuntimeSharedControlConnection(server.pairing)
|
||||
const states: string[] = []
|
||||
const connection = new RemoteRuntimeSharedControlConnection(server.pairing, {
|
||||
onDiagnosticsChanged: ({ state }) => states.push(state)
|
||||
})
|
||||
|
||||
const first = await connection.request('worktree.ps', undefined, 1000)
|
||||
const second = await connection.request('session.tabs.listAll', null, 1000)
|
||||
@@ -39,8 +42,9 @@ describe('RemoteRuntimeSharedControlConnection', () => {
|
||||
'worktree.ps',
|
||||
'session.tabs.listAll'
|
||||
])
|
||||
|
||||
connection.close()
|
||||
expect((connection.close(), states)).toEqual(
|
||||
expect.arrayContaining(['awaiting_ready', 'ready', 'closed'])
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves orchestration authority fields on shared-control requests', async () => {
|
||||
@@ -679,7 +683,8 @@ describe('RemoteRuntimeSharedControlConnection', () => {
|
||||
pendingRequestCount: 0,
|
||||
lastClose: { code: 4001, reason: 'test close' }
|
||||
})
|
||||
|
||||
connection.pauseStandingRetry()
|
||||
expect(connection.getDiagnostics()).toMatchObject({ state: 'closed' })
|
||||
connection.close()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,24 +4,30 @@ import type { RemoteRuntimeClientError } from './remote-runtime-client-error'
|
||||
import { remoteRuntimeClientCapabilities } from './remote-runtime-client-capabilities'
|
||||
import { remoteRuntimeUnavailableError } from './remote-runtime-request-frames'
|
||||
import { openSharedControlSocket } from './remote-runtime-shared-control-open'
|
||||
import { handleSharedControlTextFrame } from './remote-runtime-shared-control-frame-handler'
|
||||
import * as sharedControlProtocol from './remote-runtime-shared-control-protocol'
|
||||
import * as sharedControlReady from './remote-runtime-shared-control-ready'
|
||||
import * as sharedControlProtocol from './remote-runtime-shared-control-protocol'
|
||||
import { SharedControlReconnectScheduler } from './remote-runtime-shared-control-reconnect'
|
||||
import { requestSharedControl } from './remote-runtime-shared-control-requests'
|
||||
import { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids'
|
||||
import { SharedControlReadyStableResetTimer } from './remote-runtime-shared-control-stability'
|
||||
import * as sharedControlState from './remote-runtime-shared-control-state'
|
||||
import * as sharedControlSend from './remote-runtime-shared-control-send'
|
||||
import { closeSharedControlSocket } from './remote-runtime-shared-control-socket-close'
|
||||
import { closeSharedControlConnectionSubscription } from './remote-runtime-shared-control-subscription-close'
|
||||
import * as sharedControlSubscriptions from './remote-runtime-shared-control-subscriptions'
|
||||
import { startSharedControlSubscription } from './remote-runtime-shared-control-subscription-start'
|
||||
import { SharedControlSocketGeneration } from './remote-runtime-shared-control-socket-generation'
|
||||
import { refreshRemoteRuntimeSharedControl } from './remote-runtime-shared-control-refresh'
|
||||
import { SharedControlDiagnosticsTracker } from './remote-runtime-shared-control-diagnostics'
|
||||
import { ensureSharedControlReady } from './remote-runtime-shared-control-ready-wait'
|
||||
import { handleRuntimeControlTextFrame } from './remote-runtime-shared-control-connection-frame'
|
||||
import {
|
||||
closeSharedControlSubscription,
|
||||
replayRuntimeControlSubscriptions,
|
||||
sendSharedControlRequest,
|
||||
sendSharedControlSubscription
|
||||
} from './remote-runtime-shared-control-connection-actions'
|
||||
import type * as SharedControlTypes from './remote-runtime-shared-control-types'
|
||||
type PendingRequest = SharedControlTypes.SharedControlPendingRequest<unknown>
|
||||
type LogicalSubscription = SharedControlTypes.SharedControlLogicalSubscription<unknown>
|
||||
|
||||
export class RemoteRuntimeSharedControlConnection {
|
||||
private state: SharedControlTypes.SharedControlConnectionState = 'closed'
|
||||
private ws: WebSocket | null = null
|
||||
@@ -30,26 +36,23 @@ export class RemoteRuntimeSharedControlConnection {
|
||||
private readonly reconnect = new SharedControlReconnectScheduler()
|
||||
private readonly readyStableReset: SharedControlReadyStableResetTimer
|
||||
private intentionallyClosed = false
|
||||
private readonly diag = {
|
||||
lastConnectedAt: null as number | null,
|
||||
lastClose: null as { code: number; reason: string } | null,
|
||||
lastError: null as string | null
|
||||
}
|
||||
private readonly diagnostics: SharedControlDiagnosticsTracker
|
||||
private readonly pendingRequests = new Map<string, PendingRequest>()
|
||||
private readonly subscriptions = new Map<string, LogicalSubscription>()
|
||||
private readonly retiredRequestIds = new SharedControlRetiredRequestIds()
|
||||
private readonly readyWaiters: SharedControlTypes.SharedControlReadyWaiter[] = []
|
||||
private everReady = false
|
||||
private readonly socketGeneration = new SharedControlSocketGeneration()
|
||||
|
||||
constructor(
|
||||
private readonly pairing: PairingOffer,
|
||||
private readonly options: SharedControlTypes.RemoteRuntimeSharedControlConnectionOptions = {}
|
||||
) {
|
||||
this.diagnostics = new SharedControlDiagnosticsTracker(options)
|
||||
this.readyStableReset = new SharedControlReadyStableResetTimer(
|
||||
options.reconnectStableResetMs ?? 30_000
|
||||
)
|
||||
}
|
||||
|
||||
request<TResult>(
|
||||
method: string,
|
||||
params: unknown,
|
||||
@@ -65,12 +68,18 @@ export class RemoteRuntimeSharedControlConnection {
|
||||
timeoutMs,
|
||||
envelope,
|
||||
ensureReady: () => this.ensureReadyWithTimeout(timeoutMs, signal),
|
||||
send: (requestId) => this.sendRequest(requestId),
|
||||
send: (requestId) =>
|
||||
sendSharedControlRequest({
|
||||
pendingRequests: this.pendingRequests,
|
||||
requestId,
|
||||
state: this.state,
|
||||
ws: this.ws,
|
||||
sharedKey: this.sharedKey
|
||||
}),
|
||||
retireRequestId: (requestId) => this.retiredRequestIds.retire(requestId),
|
||||
signal
|
||||
})
|
||||
}
|
||||
|
||||
async subscribe<TResult>(
|
||||
method: string,
|
||||
params: unknown,
|
||||
@@ -84,11 +93,16 @@ export class RemoteRuntimeSharedControlConnection {
|
||||
params,
|
||||
callbacks,
|
||||
ensureReady: () => this.ensureReadyWithTimeout(timeoutMs),
|
||||
sendSubscription: (subscription) => this.sendSubscription(subscription),
|
||||
sendSubscription: (subscription) =>
|
||||
sendSharedControlSubscription({
|
||||
subscriptions: this.subscriptions,
|
||||
subscription,
|
||||
deviceToken: this.pairing.deviceToken,
|
||||
send: (payload) => this.sendEncrypted(payload)
|
||||
}),
|
||||
closeSubscription: (requestId) => this.closeSubscription(requestId)
|
||||
})
|
||||
}
|
||||
|
||||
close(error?: Error): void {
|
||||
this.intentionallyClosed = true
|
||||
this.socketGeneration.invalidate()
|
||||
@@ -97,31 +111,42 @@ export class RemoteRuntimeSharedControlConnection {
|
||||
this.closeSubscription(subscription.requestId)
|
||||
}
|
||||
this.closeSocket(error)
|
||||
this.publishDiagnostics()
|
||||
}
|
||||
|
||||
readonly retryNow = (): boolean => this.reconnect.retryNow()
|
||||
|
||||
pauseStandingRetry(): void {
|
||||
if (this.subscriptions.size === 0) {
|
||||
this.reconnect.clear()
|
||||
this.publishDiagnostics()
|
||||
}
|
||||
}
|
||||
|
||||
getDiagnostics(): SharedControlTypes.RemoteRuntimeSharedConnectionDiagnostics {
|
||||
return sharedControlState.buildSharedControlDiagnostics({
|
||||
private publishDiagnostics(): void {
|
||||
this.diagnostics.publish({
|
||||
state: this.state,
|
||||
reconnecting: this.reconnect.isScheduled,
|
||||
pendingRequestCount: this.pendingRequests.size,
|
||||
subscriptionCount: this.subscriptions.size,
|
||||
reconnectAttempt: this.reconnect.attemptCount,
|
||||
diag: this.diag
|
||||
reconnectAttempt: this.reconnect.attemptCount
|
||||
})
|
||||
}
|
||||
getDiagnostics(): SharedControlTypes.RemoteRuntimeSharedConnectionDiagnostics {
|
||||
return this.diagnostics.get({
|
||||
state: this.state,
|
||||
reconnecting: this.reconnect.isScheduled,
|
||||
pendingRequestCount: this.pendingRequests.size,
|
||||
subscriptionCount: this.subscriptions.size,
|
||||
reconnectAttempt: this.reconnect.attemptCount
|
||||
})
|
||||
}
|
||||
|
||||
reconnectNow(): void {
|
||||
refreshRemoteRuntimeSharedControl({
|
||||
intentionallyClosed: this.intentionallyClosed,
|
||||
ready: this.isReady(),
|
||||
ready: sharedControlReady.isSharedControlReady({
|
||||
state: this.state,
|
||||
ws: this.ws,
|
||||
sharedKey: this.sharedKey
|
||||
}),
|
||||
refresh: () => {
|
||||
this.closeSocket(
|
||||
remoteRuntimeUnavailableError('Refreshing remote runtime control transport.'),
|
||||
@@ -131,12 +156,11 @@ export class RemoteRuntimeSharedControlConnection {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private ensureReadyWithTimeout(timeoutMs: number, signal?: AbortSignal): Promise<void> {
|
||||
if (this.isReady()) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return sharedControlReady.waitForSharedControlReadyWithTimeout({
|
||||
return ensureSharedControlReady({
|
||||
state: this.state,
|
||||
ws: this.ws,
|
||||
sharedKey: this.sharedKey,
|
||||
readyWaiters: this.readyWaiters,
|
||||
timeoutMs,
|
||||
signal,
|
||||
@@ -144,14 +168,6 @@ export class RemoteRuntimeSharedControlConnection {
|
||||
})
|
||||
}
|
||||
|
||||
private isReady(): boolean {
|
||||
return sharedControlReady.isSharedControlReady({
|
||||
state: this.state,
|
||||
ws: this.ws,
|
||||
sharedKey: this.sharedKey
|
||||
})
|
||||
}
|
||||
|
||||
private open(): void {
|
||||
if (this.intentionallyClosed) {
|
||||
sharedControlState.rejectSharedControlReadyWaiters(
|
||||
@@ -166,7 +182,7 @@ export class RemoteRuntimeSharedControlConnection {
|
||||
getCurrentSocket: () => this.ws,
|
||||
onClose: (close, error) => {
|
||||
if (this.socketGeneration.isCurrent(socketGeneration)) {
|
||||
this.diag.lastClose = close
|
||||
this.diagnostics.markClose(close)
|
||||
}
|
||||
this.handleSocketClosed(error, socketGeneration)
|
||||
},
|
||||
@@ -185,16 +201,16 @@ export class RemoteRuntimeSharedControlConnection {
|
||||
this.sharedKey = opened.socket.sharedKey
|
||||
this.socketCleanup = opened.socket.cleanup
|
||||
this.state = 'awaiting_ready'
|
||||
this.publishDiagnostics()
|
||||
}
|
||||
|
||||
private handleTextFrame(frame: string, socketGeneration: number): void {
|
||||
if (!this.socketGeneration.isCurrent(socketGeneration)) {
|
||||
return
|
||||
}
|
||||
handleSharedControlTextFrame({
|
||||
handleRuntimeControlTextFrame({
|
||||
frame,
|
||||
state: this.state,
|
||||
sharedKey: this.sharedKey,
|
||||
socketGeneration,
|
||||
isCurrent: (generation) => this.socketGeneration.isCurrent(generation),
|
||||
getState: () => this.state,
|
||||
getSharedKey: () => this.sharedKey,
|
||||
environmentId: this.options.environmentId,
|
||||
deviceToken: this.pairing.deviceToken,
|
||||
clientCapabilities: remoteRuntimeClientCapabilities(this.options.clientCapabilities),
|
||||
@@ -208,57 +224,32 @@ export class RemoteRuntimeSharedControlConnection {
|
||||
handleSocketClosed: (error) => this.handleSocketClosed(error, socketGeneration),
|
||||
sendEncrypted: (payload) => this.sendEncrypted(payload),
|
||||
markReady: () => {
|
||||
this.diag.lastConnectedAt = Date.now()
|
||||
this.diagnostics.markReady()
|
||||
// Why cleared here: these describe the attempt that just succeeded's predecessor.
|
||||
// Left set, a recovered host reads "Connected" next to a stale failure forever.
|
||||
this.diag.lastError = null
|
||||
this.diag.lastClose = null
|
||||
this.publishDiagnostics()
|
||||
this.readyStableReset.schedule({
|
||||
getState: () => this.state,
|
||||
getSocket: () => this.ws,
|
||||
reset: () => this.reconnect.resetAttempt()
|
||||
})
|
||||
},
|
||||
replaySubscriptions: () => this.replaySubscriptions()
|
||||
})
|
||||
}
|
||||
|
||||
private sendRequest(requestId: string): void {
|
||||
sharedControlSend.sendSharedControlRequest({
|
||||
pendingRequests: this.pendingRequests,
|
||||
requestId,
|
||||
send: (serialized) =>
|
||||
sharedControlProtocol.sendSharedControlEncryptedSerialized({
|
||||
state: this.state,
|
||||
ws: this.ws,
|
||||
sharedKey: this.sharedKey,
|
||||
serialized
|
||||
}),
|
||||
reject: (id, error) =>
|
||||
sharedControlState.rejectSharedControlPendingRequest(this.pendingRequests, id, error)
|
||||
})
|
||||
}
|
||||
|
||||
private sendSubscription(subscription: LogicalSubscription): void {
|
||||
sharedControlSend.sendSharedControlSubscription({
|
||||
subscriptions: this.subscriptions,
|
||||
subscription,
|
||||
deviceToken: this.pairing.deviceToken,
|
||||
send: (payload) => this.sendEncrypted(payload)
|
||||
replaySubscriptions: () => this.replaySubscriptions(),
|
||||
publishDiagnostics: () => this.publishDiagnostics()
|
||||
})
|
||||
}
|
||||
|
||||
private replaySubscriptions(): void {
|
||||
sharedControlSubscriptions.replaySharedControlSubscriptions({
|
||||
this.everReady = replayRuntimeControlSubscriptions({
|
||||
subscriptions: this.subscriptions,
|
||||
send: (subscription) => this.sendSubscription(subscription),
|
||||
deviceToken: this.pairing.deviceToken,
|
||||
send: (payload) => this.sendEncrypted(payload),
|
||||
tagReplayedResponses: this.everReady
|
||||
})
|
||||
this.everReady = true
|
||||
}
|
||||
|
||||
private closeSubscription(requestId: string): void {
|
||||
closeSharedControlConnectionSubscription({
|
||||
closeSharedControlSubscription({
|
||||
subscriptions: this.subscriptions,
|
||||
retiredRequestIds: this.retiredRequestIds,
|
||||
requestId,
|
||||
@@ -288,7 +279,7 @@ export class RemoteRuntimeSharedControlConnection {
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.diag.lastError = error.message
|
||||
this.diagnostics.markError(error.message)
|
||||
this.reconnect.scheduleAfterSocketClose({
|
||||
intentionallyClosed: this.intentionallyClosed,
|
||||
manuallyDisconnected: this.options.isManuallyDisconnected?.() ?? false,
|
||||
@@ -296,6 +287,7 @@ export class RemoteRuntimeSharedControlConnection {
|
||||
subscriptionCount: this.subscriptions.size,
|
||||
open: () => this.open()
|
||||
})
|
||||
this.publishDiagnostics()
|
||||
}
|
||||
|
||||
private closeSocket(error?: Error, preserveReadyWaitersAndPendingRequests = false): void {
|
||||
@@ -305,7 +297,7 @@ export class RemoteRuntimeSharedControlConnection {
|
||||
pendingRequests: this.pendingRequests,
|
||||
subscriptions: this.subscriptions,
|
||||
readyWaiters: this.readyWaiters,
|
||||
lastClose: this.diag.lastClose,
|
||||
lastClose: this.getDiagnostics().lastClose,
|
||||
socketCleanup: this.socketCleanup,
|
||||
ws: this.ws,
|
||||
error,
|
||||
@@ -315,5 +307,6 @@ export class RemoteRuntimeSharedControlConnection {
|
||||
this.ws = this.sharedKey = null
|
||||
this.socketCleanup = null
|
||||
this.state = 'closed'
|
||||
this.publishDiagnostics()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type {
|
||||
RemoteRuntimeSharedConnectionDiagnostics,
|
||||
RemoteRuntimeSharedControlConnectionOptions,
|
||||
SharedControlConnectionState
|
||||
} from './remote-runtime-shared-control-types'
|
||||
|
||||
type DiagnosticClose = { code: number; reason: string } | null
|
||||
|
||||
export class SharedControlDiagnosticsTracker {
|
||||
private lastConnectedAt: number | null = null
|
||||
private lastClose: DiagnosticClose = null
|
||||
private lastError: string | null = null
|
||||
private lastPublished: RemoteRuntimeSharedConnectionDiagnostics | null = null
|
||||
|
||||
constructor(private readonly options: RemoteRuntimeSharedControlConnectionOptions) {}
|
||||
|
||||
markClose(close: DiagnosticClose): void {
|
||||
this.lastClose = close
|
||||
}
|
||||
|
||||
markReady(): void {
|
||||
this.lastConnectedAt = Date.now()
|
||||
this.lastError = null
|
||||
this.lastClose = null
|
||||
}
|
||||
|
||||
markError(error: string): void {
|
||||
this.lastError = error
|
||||
}
|
||||
|
||||
get(args: {
|
||||
state: SharedControlConnectionState
|
||||
reconnecting: boolean
|
||||
pendingRequestCount: number
|
||||
subscriptionCount: number
|
||||
reconnectAttempt: number
|
||||
}): RemoteRuntimeSharedConnectionDiagnostics {
|
||||
return {
|
||||
state: args.reconnecting ? 'reconnecting' : args.state,
|
||||
pendingRequestCount: args.pendingRequestCount,
|
||||
subscriptionCount: args.subscriptionCount,
|
||||
reconnectAttempt: args.reconnectAttempt,
|
||||
lastConnectedAt: this.lastConnectedAt,
|
||||
lastClose: this.lastClose,
|
||||
lastError: this.lastError
|
||||
}
|
||||
}
|
||||
|
||||
publish(args: Parameters<SharedControlDiagnosticsTracker['get']>[0]): void {
|
||||
const diagnostics = this.get(args)
|
||||
const previous = this.lastPublished
|
||||
const closeUnchanged =
|
||||
previous?.lastClose?.code === diagnostics.lastClose?.code &&
|
||||
previous?.lastClose?.reason === diagnostics.lastClose?.reason
|
||||
if (
|
||||
previous &&
|
||||
previous.state === diagnostics.state &&
|
||||
previous.pendingRequestCount === diagnostics.pendingRequestCount &&
|
||||
previous.subscriptionCount === diagnostics.subscriptionCount &&
|
||||
previous.reconnectAttempt === diagnostics.reconnectAttempt &&
|
||||
previous.lastConnectedAt === diagnostics.lastConnectedAt &&
|
||||
closeUnchanged &&
|
||||
previous.lastError === diagnostics.lastError
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.lastPublished = diagnostics
|
||||
try {
|
||||
this.options.onDiagnosticsChanged?.(diagnostics)
|
||||
} catch (error) {
|
||||
console.warn('[remote-runtime.shared-control] diagnostics callback failed:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type WebSocket from 'ws'
|
||||
import type {
|
||||
SharedControlConnectionState,
|
||||
SharedControlReadyWaiter
|
||||
} from './remote-runtime-shared-control-types'
|
||||
import {
|
||||
isSharedControlReady,
|
||||
waitForSharedControlReadyWithTimeout
|
||||
} from './remote-runtime-shared-control-ready'
|
||||
|
||||
export function ensureSharedControlReady(args: {
|
||||
state: SharedControlConnectionState
|
||||
ws: WebSocket | null
|
||||
sharedKey: Uint8Array | null
|
||||
readyWaiters: SharedControlReadyWaiter[]
|
||||
timeoutMs: number
|
||||
signal?: AbortSignal
|
||||
open: () => void
|
||||
}): Promise<void> {
|
||||
if (isSharedControlReady(args)) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return waitForSharedControlReadyWithTimeout({
|
||||
readyWaiters: args.readyWaiters,
|
||||
timeoutMs: args.timeoutMs,
|
||||
signal: args.signal,
|
||||
open: args.open
|
||||
})
|
||||
}
|
||||
@@ -77,6 +77,8 @@ export type RemoteRuntimeSharedControlConnectionOptions = {
|
||||
clientCapabilities?: readonly RuntimeCapability[]
|
||||
isManuallyDisconnected?: () => boolean
|
||||
isCapabilityPaused?: () => boolean
|
||||
/** Publishes local transport diagnostics after a meaningful state transition. */
|
||||
onDiagnosticsChanged?: (diagnostics: RemoteRuntimeSharedConnectionDiagnostics) => void
|
||||
reconnectStableResetMs?: number
|
||||
liveness?: RemoteRuntimeSocketLivenessOptions
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL =
|
||||
'runtimeEnvironments:sharedControlDiagnostics'
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { RuntimeStatus } from './runtime-session-contracts'
|
||||
import { isRuntimeWorkspaceWindowClosed } from './runtime-workspace-window-availability'
|
||||
|
||||
export type RuntimeHostConnectionState =
|
||||
| 'connected'
|
||||
| 'workspace-window-closed'
|
||||
| 'checking'
|
||||
| 'reconnecting'
|
||||
| 'disconnected'
|
||||
|
||||
/** Derives the runtime transport verdict shared by the renderer and agents. */
|
||||
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') {
|
||||
return 'disconnected'
|
||||
}
|
||||
if (remoteControl && remoteControl.state !== 'ready') {
|
||||
return 'checking'
|
||||
}
|
||||
if (isRuntimeWorkspaceWindowClosed(status)) {
|
||||
return 'workspace-window-closed'
|
||||
}
|
||||
return 'connected'
|
||||
}
|
||||
|
||||
export function isConnectedRuntimeHostState(state: RuntimeHostConnectionState): boolean {
|
||||
return state === 'connected' || state === 'workspace-window-closed'
|
||||
}
|
||||
|
||||
export type HostStatus = 'connected' | 'disconnected' | 'connecting'
|
||||
|
||||
export function runtimeStatusForOverall(state: RuntimeHostConnectionState): HostStatus {
|
||||
switch (state) {
|
||||
case 'connected':
|
||||
case 'workspace-window-closed':
|
||||
return 'connected'
|
||||
case 'checking':
|
||||
case 'reconnecting':
|
||||
return 'connecting'
|
||||
case 'disconnected':
|
||||
return 'disconnected'
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AgentStatusOrchestrationContext } from './agent-status-types'
|
||||
import type { RemoteServerUpdateSupport } from './remote-server-update'
|
||||
import type { RemoteRuntimeSharedConnectionDiagnostics } from './remote-runtime-shared-control-types'
|
||||
import type { RuntimeHostConnectionState } from './runtime-host-connection-state'
|
||||
import type { RuntimeCapability } from './protocol-version'
|
||||
import type {
|
||||
RuntimeBrowserUnavailableReason,
|
||||
@@ -111,6 +112,8 @@ export type CliStatusResult = {
|
||||
runtime: {
|
||||
state: CliRuntimeState
|
||||
reachable: boolean
|
||||
/** Canonical runtime transport verdict, when the caller has runtime evidence. */
|
||||
connectionState?: RuntimeHostConnectionState
|
||||
runtimeId: string | null
|
||||
appVersion?: string
|
||||
remoteUpdateSupport?: RemoteServerUpdateSupport
|
||||
|
||||
Reference in New Issue
Block a user