mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(runtime): show "Re-pair required" for revoked servers and re-pair in place (#30271)
* fix(runtime): show Re-pair required for revoked servers and re-pair in place A server that revokes this computer's pairing token was labeled "Update required" because auth refusals and protocol mismatches shared one blocked state. Carry the blocked reason locally, label revoked hosts "Re-pair required", and add a Re-pair action (sidebar host menu + Settings server row) that verifies a new pairing link reaches the same server before replacing the saved pairing. * fix(sidebar): stop host header menu clicks from toggling or dragging the row * fix(sidebar): vertically center host header label and menu button
This commit is contained in:
@@ -2,6 +2,7 @@ export const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [
|
||||
'runtimeEnvironments:list',
|
||||
'runtimeEnvironments:addFromPairingCode',
|
||||
'runtimeEnvironments:verifyAndAddFromPairingCode',
|
||||
'runtimeEnvironments:repairFromPairingCode',
|
||||
'runtimeEnvironments:resolve',
|
||||
'runtimeEnvironments:remove',
|
||||
'runtimeEnvironments:disconnect',
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import { pairedRuntimeConnectionFromOffer } from '../../shared/paired-runtime-connection'
|
||||
import {
|
||||
addEnvironmentFromPairingCode,
|
||||
RuntimeEnvironmentStoreError
|
||||
resolveEnvironment,
|
||||
RuntimeEnvironmentStoreError,
|
||||
updateEnvironmentFromPairingCode
|
||||
} from '../../shared/runtime-environment-store'
|
||||
import { parseHostAccessLink } from '../../shared/remote-pairing-address'
|
||||
import { parseHostAccessLink, type ParsedHostAccessLink } from '../../shared/remote-pairing-address'
|
||||
import {
|
||||
verifyRemotePairingRuntimeStatus,
|
||||
type RemotePairingFailure,
|
||||
type VerifyAndAddRuntimeEnvironmentResult
|
||||
} from '../../shared/remote-pairing-verification'
|
||||
import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error'
|
||||
import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client'
|
||||
import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities'
|
||||
import { redactRuntimeEnvironment } from '../../shared/runtime-environments'
|
||||
import {
|
||||
getPreferredPairingOffer,
|
||||
isEphemeralVmRuntimeEnvironment,
|
||||
redactRuntimeEnvironment
|
||||
} from '../../shared/runtime-environments'
|
||||
import type { RuntimeStatus } from '../../shared/runtime-types'
|
||||
|
||||
type VerifyAndAddRuntimeEnvironmentArgs = {
|
||||
@@ -20,23 +27,28 @@ type VerifyAndAddRuntimeEnvironmentArgs = {
|
||||
allowLoopback?: boolean
|
||||
}
|
||||
|
||||
export async function verifyAndAddRuntimeEnvironmentFromPairingCode(
|
||||
userDataPath: string,
|
||||
args: VerifyAndAddRuntimeEnvironmentArgs
|
||||
): Promise<VerifyAndAddRuntimeEnvironmentResult> {
|
||||
const parsed = parseHostAccessLink(args.pairingCode)
|
||||
type VerifiedPairingCode = {
|
||||
ok: true
|
||||
link: ParsedHostAccessLink
|
||||
runtimeStatus: RuntimeStatus
|
||||
}
|
||||
|
||||
/** Proves a pairing link reaches a compatible host that accepts it, without saving anything. */
|
||||
async function verifyPairingCode(
|
||||
pairingCode: string,
|
||||
allowLoopback: boolean | undefined
|
||||
): Promise<VerifiedPairingCode | RemotePairingFailure> {
|
||||
const parsed = parseHostAccessLink(pairingCode)
|
||||
if (!parsed.ok) {
|
||||
return { ok: false, kind: 'access-link-invalid', message: parsed.message }
|
||||
}
|
||||
if (parsed.value.endpointKind === 'loopback' && !args.allowLoopback) {
|
||||
if (parsed.value.endpointKind === 'loopback' && !allowLoopback) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'host-unreachable',
|
||||
message: 'This access link points back to this device.'
|
||||
}
|
||||
}
|
||||
|
||||
let runtimeStatus: RuntimeStatus
|
||||
try {
|
||||
const response = await sendRemoteRuntimeRequest<RuntimeStatus>(
|
||||
pairedRuntimeConnectionFromOffer(parsed.value.pairing),
|
||||
@@ -58,12 +70,26 @@ export async function verifyAndAddRuntimeEnvironmentFromPairingCode(
|
||||
if (!statusVerification.ok) {
|
||||
return statusVerification
|
||||
}
|
||||
runtimeStatus = statusVerification.runtimeStatus
|
||||
return {
|
||||
ok: true,
|
||||
link: parsed.value,
|
||||
runtimeStatus: statusVerification.runtimeStatus
|
||||
}
|
||||
} catch (error) {
|
||||
return classifyPairingVerificationError(error, parsed.value.displayEndpoint)
|
||||
}
|
||||
}
|
||||
|
||||
const usesSshTunnel = parsed.value.endpointKind === 'loopback' && args.allowLoopback === true
|
||||
export async function verifyAndAddRuntimeEnvironmentFromPairingCode(
|
||||
userDataPath: string,
|
||||
args: VerifyAndAddRuntimeEnvironmentArgs
|
||||
): Promise<VerifyAndAddRuntimeEnvironmentResult> {
|
||||
const verified = await verifyPairingCode(args.pairingCode, args.allowLoopback)
|
||||
if (!verified.ok) {
|
||||
return verified
|
||||
}
|
||||
const { link, runtimeStatus } = verified
|
||||
const usesSshTunnel = link.endpointKind === 'loopback' && args.allowLoopback === true
|
||||
let environment: ReturnType<typeof addEnvironmentFromPairingCode>
|
||||
try {
|
||||
environment = addEnvironmentFromPairingCode(userDataPath, {
|
||||
@@ -87,10 +113,64 @@ export async function verifyAndAddRuntimeEnvironmentFromPairingCode(
|
||||
}
|
||||
}
|
||||
|
||||
function classifyPairingVerificationError(
|
||||
error: unknown,
|
||||
endpoint: string
|
||||
): VerifyAndAddRuntimeEnvironmentResult {
|
||||
/**
|
||||
* Replaces a saved server's revoked pairing with a fresh link to the same server. Nothing is
|
||||
* written unless the link verifies and carries the saved server's key, so a bad or foreign link
|
||||
* leaves the working record untouched.
|
||||
*/
|
||||
export async function verifyAndRepairRuntimeEnvironmentFromPairingCode(
|
||||
userDataPath: string,
|
||||
args: { selector: string; pairingCode: string }
|
||||
): Promise<VerifyAndAddRuntimeEnvironmentResult> {
|
||||
const existing = resolveEnvironment(userDataPath, args.selector)
|
||||
if (isEphemeralVmRuntimeEnvironment(existing)) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'environment-save-failed',
|
||||
message: 'This server belongs to a cloud workspace, which pairs it again when it resumes.'
|
||||
}
|
||||
}
|
||||
// Why: a tunnel-backed server is legitimately re-paired with a loopback link.
|
||||
const verified = await verifyPairingCode(
|
||||
args.pairingCode,
|
||||
existing.connectionDependency === 'ssh-tunnel'
|
||||
)
|
||||
if (!verified.ok) {
|
||||
return verified
|
||||
}
|
||||
// Why the key, not runtimeId: runtimeId is minted per server process, so a restart changes it.
|
||||
if (verified.link.pairing.publicKeyB64 !== getPreferredPairingOffer(existing).publicKeyB64) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'different-server',
|
||||
message: 'This pairing link is for a different server.'
|
||||
}
|
||||
}
|
||||
let environment: ReturnType<typeof updateEnvironmentFromPairingCode>
|
||||
try {
|
||||
environment = updateEnvironmentFromPairingCode(userDataPath, existing.id, {
|
||||
pairingCode: args.pairingCode,
|
||||
// Why: the key match proves the same server; a restarted one may report a new runtimeId.
|
||||
runtimeId: verified.runtimeStatus.runtimeId
|
||||
})
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'environment-save-failed',
|
||||
message:
|
||||
error instanceof RuntimeEnvironmentStoreError && error.code === 'invalid_argument'
|
||||
? error.message
|
||||
: 'Orca verified the server but could not save the new pairing. Check local settings storage and try again.'
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
environment: redactRuntimeEnvironment(environment),
|
||||
runtimeStatus: verified.runtimeStatus
|
||||
}
|
||||
}
|
||||
|
||||
function classifyPairingVerificationError(error: unknown, endpoint: string): RemotePairingFailure {
|
||||
if (error instanceof RemoteRuntimeClientError) {
|
||||
if (error.code === 'invalid_argument') {
|
||||
return invalidAccessLinkResult()
|
||||
@@ -128,7 +208,7 @@ function classifyPairingVerificationError(
|
||||
return unreachableHostResult(endpoint)
|
||||
}
|
||||
|
||||
function invalidAccessLinkResult(): VerifyAndAddRuntimeEnvironmentResult {
|
||||
function invalidAccessLinkResult(): RemotePairingFailure {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'access-link-invalid',
|
||||
@@ -136,7 +216,7 @@ function invalidAccessLinkResult(): VerifyAndAddRuntimeEnvironmentResult {
|
||||
}
|
||||
}
|
||||
|
||||
function unreachableHostResult(endpoint: string): VerifyAndAddRuntimeEnvironmentResult {
|
||||
function unreachableHostResult(endpoint: string): RemotePairingFailure {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'host-unreachable',
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import type { VerifyAndAddRuntimeEnvironmentResult } from '../../shared/remote-pairing-verification'
|
||||
import { verifyAndRepairRuntimeEnvironmentFromPairingCode } from './runtime-environment-pairing-verification'
|
||||
import { getRuntimeEnvironmentStatusOwner } from './runtime-environment-request-connections'
|
||||
import { clearRuntimeEnvironmentManualDisconnect } from './runtime-environment-manual-disconnect'
|
||||
|
||||
export function registerRuntimeEnvironmentRepairHandler({
|
||||
getUserDataPath,
|
||||
invalidateTransport
|
||||
}: {
|
||||
getUserDataPath: () => string
|
||||
invalidateTransport: (environmentId: string) => Promise<void> | void
|
||||
}): void {
|
||||
ipcMain.handle(
|
||||
'runtimeEnvironments:repairFromPairingCode',
|
||||
async (
|
||||
_event,
|
||||
args: { selector: string; pairingCode: string }
|
||||
): Promise<VerifyAndAddRuntimeEnvironmentResult> => {
|
||||
const result = await verifyAndRepairRuntimeEnvironmentFromPairingCode(getUserDataPath(), args)
|
||||
if (!result.ok) {
|
||||
return result
|
||||
}
|
||||
const environmentId = result.environment.id
|
||||
// Why before invalidating: a manually disconnected server would not get a fresh owner.
|
||||
clearRuntimeEnvironmentManualDisconnect(environmentId)
|
||||
// Retires the owner blocked under the revoked pairing; its replacement keys on the new revision.
|
||||
await invalidateTransport(environmentId)
|
||||
getRuntimeEnvironmentStatusOwner(getUserDataPath(), environmentId).acceptVerified({
|
||||
id: 'status.get',
|
||||
ok: true,
|
||||
result: result.runtimeStatus,
|
||||
_meta: { runtimeId: result.runtimeStatus.runtimeId }
|
||||
})
|
||||
return result
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -58,12 +58,15 @@ export function withRuntimeStatusOwners<T extends Record<string, Mock>>(transpor
|
||||
}
|
||||
}
|
||||
|
||||
export function pairingCode(endpoint = 'ws://127.0.0.1:6768'): string {
|
||||
export function pairingCode(
|
||||
endpoint = 'ws://127.0.0.1:6768',
|
||||
{ deviceToken = 'device-token', keyByte = 1 }: { deviceToken?: string; keyByte?: number } = {}
|
||||
): string {
|
||||
return encodePairingOffer({
|
||||
v: 2,
|
||||
endpoint,
|
||||
deviceToken: 'device-token',
|
||||
publicKeyB64: Buffer.from(new Uint8Array(32).fill(1)).toString('base64')
|
||||
deviceToken,
|
||||
publicKeyB64: Buffer.from(new Uint8Array(32).fill(keyByte)).toString('base64')
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as environmentStore from '../../shared/runtime-environment-store'
|
||||
import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error'
|
||||
|
||||
const { handleMock, getPathMock, sendRemoteRuntimeRequestMock } = vi.hoisted(() => ({
|
||||
handleMock: vi.fn(),
|
||||
getPathMock: vi.fn(),
|
||||
sendRemoteRuntimeRequestMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: { getAllWindows: () => [] },
|
||||
app: { getPath: getPathMock },
|
||||
ipcMain: {
|
||||
handle: handleMock,
|
||||
on: vi.fn(),
|
||||
removeHandler: vi.fn(),
|
||||
removeAllListeners: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../../shared/remote-runtime-client', () => ({
|
||||
sendRemoteRuntimeRequest: sendRemoteRuntimeRequestMock,
|
||||
subscribeRemoteRuntimeRequest: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./runtime-environment-request-connections', async () => {
|
||||
const { withRuntimeStatusOwners } = await import('./runtime-environments-ipc-test-harness')
|
||||
return withRuntimeStatusOwners({
|
||||
sendRemoteRuntimeConnectionRequest: vi.fn(),
|
||||
sendRemoteRuntimeSharedControlRequest: vi.fn(() => new Promise(() => {})),
|
||||
subscribeRemoteRuntimeSharedControlRequest: vi.fn(),
|
||||
getRemoteRuntimeSharedControlDiagnostics: vi.fn(() => null),
|
||||
reconnectRemoteRuntimeSharedControlConnection: vi.fn(),
|
||||
retryRemoteRuntimeSharedControlConnectionsNow: vi.fn(),
|
||||
retryRemoteRuntimeSharedControlConnectionNow: vi.fn(),
|
||||
ensureRemoteRuntimeSharedControlConnection: vi.fn(),
|
||||
pauseRemoteRuntimeSharedControlRetry: vi.fn(),
|
||||
closeRemoteRuntimeRequestConnection: vi.fn()
|
||||
})
|
||||
})
|
||||
|
||||
import { registerRuntimeEnvironmentHandlers } from './runtime-environments'
|
||||
import {
|
||||
getRuntimeEnvironmentStatusOwner,
|
||||
getRuntimeEnvironmentStatusSnapshots,
|
||||
resetRuntimeEnvironmentStatusOwners
|
||||
} from './runtime-environment-request-connections'
|
||||
import { channelHandlerLookup, pairingCode } from './runtime-environments-ipc-test-harness'
|
||||
|
||||
const handler = channelHandlerLookup(handleMock)
|
||||
const ENDPOINT = 'ws://192.168.1.20:6768'
|
||||
|
||||
function statusResponse() {
|
||||
return {
|
||||
id: 'status',
|
||||
ok: true,
|
||||
result: {
|
||||
runtimeId: 'runtime-after-restart',
|
||||
rendererGraphEpoch: 1,
|
||||
graphStatus: 'ready',
|
||||
authoritativeWindowId: 1,
|
||||
liveTabCount: 0,
|
||||
liveLeafCount: 0,
|
||||
protocolVersion: 999_999
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-after-restart' }
|
||||
}
|
||||
}
|
||||
|
||||
describe('runtimeEnvironments:repairFromPairingCode', () => {
|
||||
let userDataPath: string
|
||||
let repair: (event: unknown, args: { selector: string; pairingCode: string }) => unknown
|
||||
|
||||
function savedToken(): string | undefined {
|
||||
return environmentStore.resolveEnvironment(userDataPath, 'desk').endpoints[0]?.deviceToken
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-env-repair-'))
|
||||
getPathMock.mockReturnValue(userDataPath)
|
||||
handleMock.mockReset()
|
||||
sendRemoteRuntimeRequestMock.mockReset()
|
||||
const store = {
|
||||
getSettings: () => ({ activeRuntimeEnvironmentId: null }),
|
||||
updateSettings: vi.fn(),
|
||||
removeWorkspaceSessionHost: vi.fn(),
|
||||
setRemoteLayoutSnapshot: vi.fn()
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The registrar only reads the fixture's settings and workspace-session methods.
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
environmentStore.addEnvironmentFromPairingCode(userDataPath, {
|
||||
name: 'desk',
|
||||
pairingCode: pairingCode(ENDPOINT, { deviceToken: 'revoked-token' })
|
||||
})
|
||||
repair = handler('runtimeEnvironments:repairFromPairingCode')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetRuntimeEnvironmentStatusOwners()
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('replaces a revoked pairing in place and clears the blocked status', async () => {
|
||||
const before = environmentStore.resolveEnvironment(userDataPath, 'desk')
|
||||
getRuntimeEnvironmentStatusOwner(userDataPath, before.id).authenticationRejected()
|
||||
expect(getRuntimeEnvironmentStatusSnapshots()).toMatchObject([
|
||||
{ verification: 'blocked', blockedReason: 'access-revoked' }
|
||||
])
|
||||
sendRemoteRuntimeRequestMock.mockResolvedValue(statusResponse())
|
||||
|
||||
const result = await repair(null, {
|
||||
selector: 'desk',
|
||||
pairingCode: pairingCode(ENDPOINT, { deviceToken: 'fresh-token' })
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
environment: { id: before.id, name: 'desk' },
|
||||
runtimeStatus: { runtimeId: 'runtime-after-restart' }
|
||||
})
|
||||
expect(JSON.stringify(result)).not.toContain('fresh-token')
|
||||
const after = environmentStore.resolveEnvironment(userDataPath, 'desk')
|
||||
expect(savedToken()).toBe('fresh-token')
|
||||
expect(after.pairingRevision).toBeGreaterThan(before.pairingRevision ?? before.createdAt)
|
||||
const snapshot = getRuntimeEnvironmentStatusSnapshots().find(
|
||||
(entry) => entry.environmentId === before.id
|
||||
)
|
||||
expect(snapshot).toMatchObject({
|
||||
verification: 'verified',
|
||||
pairingRevision: after.pairingRevision
|
||||
})
|
||||
expect(snapshot).not.toHaveProperty('blockedReason')
|
||||
})
|
||||
|
||||
it('refuses a verified link for a different server and keeps the saved pairing', async () => {
|
||||
sendRemoteRuntimeRequestMock.mockResolvedValue(statusResponse())
|
||||
|
||||
await expect(
|
||||
repair(null, {
|
||||
selector: 'desk',
|
||||
pairingCode: pairingCode(ENDPOINT, {
|
||||
deviceToken: 'other-token',
|
||||
keyByte: 2
|
||||
})
|
||||
})
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
kind: 'different-server',
|
||||
message: 'This pairing link is for a different server.'
|
||||
})
|
||||
expect(savedToken()).toBe('revoked-token')
|
||||
})
|
||||
|
||||
it('leaves the record untouched for an invalid or rejected link', async () => {
|
||||
const before = environmentStore.resolveEnvironment(userDataPath, 'desk')
|
||||
await expect(
|
||||
repair(null, { selector: 'desk', pairingCode: 'not a link' })
|
||||
).resolves.toMatchObject({ ok: false, kind: 'access-link-invalid' })
|
||||
expect(sendRemoteRuntimeRequestMock).not.toHaveBeenCalled()
|
||||
|
||||
sendRemoteRuntimeRequestMock.mockRejectedValue(
|
||||
new RemoteRuntimeClientError('unauthorized', 'rejected', {
|
||||
pairingStage: 'access-grant'
|
||||
})
|
||||
)
|
||||
await expect(
|
||||
repair(null, {
|
||||
selector: 'desk',
|
||||
pairingCode: pairingCode(ENDPOINT, { deviceToken: 'also-revoked' })
|
||||
})
|
||||
).resolves.toMatchObject({ ok: false, kind: 'access-link-invalid' })
|
||||
expect(environmentStore.resolveEnvironment(userDataPath, 'desk')).toEqual(before)
|
||||
})
|
||||
|
||||
it('declines a server another Orca service pairs', async () => {
|
||||
environmentStore.addEnvironmentFromPairingCode(userDataPath, {
|
||||
name: 'vm',
|
||||
pairingCode: pairingCode(ENDPOINT, { deviceToken: 'vm-token' }),
|
||||
source: 'ephemeral-vm'
|
||||
})
|
||||
await expect(
|
||||
repair(null, { selector: 'vm', pairingCode: pairingCode(ENDPOINT) })
|
||||
).resolves.toMatchObject({ ok: false, kind: 'environment-save-failed' })
|
||||
expect(sendRemoteRuntimeRequestMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
getRuntimeEnvironmentStatusOwner
|
||||
} from './runtime-environment-request-connections'
|
||||
import { registerRuntimeEnvironmentRecoveryHandler } from './runtime-environment-recovery-handler'
|
||||
import { registerRuntimeEnvironmentRepairHandler } from './runtime-environment-repair-handler'
|
||||
import { advanceRuntimeEnvironmentTransportGeneration } from './runtime-environment-transport-generation'
|
||||
import { resetSharedControlSupport } from './runtime-environment-transport-routing'
|
||||
import { RUNTIME_ENVIRONMENT_HANDLER_CHANNELS } from './runtime-environment-handler-channels'
|
||||
@@ -137,6 +138,10 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void {
|
||||
getSettings: () => store.getSettings()
|
||||
})
|
||||
registerRuntimeEnvironmentRecoveryHandler()
|
||||
registerRuntimeEnvironmentRepairHandler({
|
||||
getUserDataPath,
|
||||
invalidateTransport: invalidateRuntimeEnvironmentTransport
|
||||
})
|
||||
registerRuntimeEnvironmentPassiveHandlers(getUserDataPath)
|
||||
for (const environment of listEnvironments(getUserDataPath())) {
|
||||
if (
|
||||
|
||||
@@ -93,6 +93,11 @@ export type RuntimeApi = {
|
||||
pairingCode: string
|
||||
allowLoopback?: boolean
|
||||
}) => Promise<VerifyAndAddRuntimeEnvironmentResult>
|
||||
/** Replaces a revoked pairing in place; refuses a link for a different server. */
|
||||
repairFromPairingCode: (args: {
|
||||
selector: string
|
||||
pairingCode: string
|
||||
}) => Promise<VerifyAndAddRuntimeEnvironmentResult>
|
||||
resolve: (args: { selector: string }) => Promise<PublicKnownRuntimeEnvironment>
|
||||
remove: (args: { selector: string }) => Promise<{ removed: PublicKnownRuntimeEnvironment }>
|
||||
disconnect: (args: {
|
||||
|
||||
@@ -45,6 +45,11 @@ export const runtimeEnvironmentsApi = {
|
||||
allowLoopback?: boolean
|
||||
}): Promise<VerifyAndAddRuntimeEnvironmentResult> =>
|
||||
ipcRenderer.invoke('runtimeEnvironments:verifyAndAddFromPairingCode', args),
|
||||
repairFromPairingCode: (args: {
|
||||
selector: string
|
||||
pairingCode: string
|
||||
}): Promise<VerifyAndAddRuntimeEnvironmentResult> =>
|
||||
ipcRenderer.invoke('runtimeEnvironments:repairFromPairingCode', args),
|
||||
resolve: (args: { selector: string }): Promise<PublicKnownRuntimeEnvironment> =>
|
||||
ipcRenderer.invoke('runtimeEnvironments:resolve', args),
|
||||
remove: (args: { selector: string }): Promise<{ removed: PublicKnownRuntimeEnvironment }> =>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { RemotePairingFailureKind } from '../../../../shared/remote-pairing-verification'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { translateRemotePairingFailureDescription } from '@/lib/remote-pairing-copy'
|
||||
|
||||
export type RuntimeHostAccessFailure = {
|
||||
kind: RemotePairingFailureKind
|
||||
message: string
|
||||
}
|
||||
|
||||
function failureTitle(kind: RemotePairingFailureKind): string {
|
||||
switch (kind) {
|
||||
case 'host-identity-mismatch':
|
||||
return translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.identityMismatch',
|
||||
'The reached Orca host does not match this access link'
|
||||
)
|
||||
case 'access-link-invalid':
|
||||
return translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.invalidLink',
|
||||
'This access link is no longer valid'
|
||||
)
|
||||
case 'protocol-incompatible':
|
||||
return translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.incompatible',
|
||||
'Orca versions are not compatible'
|
||||
)
|
||||
case 'connection-interrupted':
|
||||
return translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.interrupted',
|
||||
'Connection interrupted'
|
||||
)
|
||||
case 'environment-save-failed':
|
||||
return translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.saveFailed',
|
||||
'Could not save the host'
|
||||
)
|
||||
case 'different-server':
|
||||
return translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.differentServer',
|
||||
'This link is for a different server'
|
||||
)
|
||||
case 'host-unreachable':
|
||||
return translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.unavailable',
|
||||
'Host unavailable'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function RuntimeHostAccessFailureAlert({
|
||||
id,
|
||||
failure,
|
||||
endpoint
|
||||
}: {
|
||||
id: string
|
||||
failure: RuntimeHostAccessFailure
|
||||
endpoint: string | null
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div id={id} role="alert" className="space-y-1 rounded-md border border-destructive/50 p-3">
|
||||
<div className="text-sm font-medium text-destructive">{failureTitle(failure.kind)}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{failure.kind === 'environment-save-failed'
|
||||
? failure.message
|
||||
: translateRemotePairingFailureDescription(failure.kind, endpoint)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ChevronDown, Loader2, Plus } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { parseHostAccessLink } from '../../../../shared/remote-pairing-address'
|
||||
import type { RemotePairingFailureKind } from '../../../../shared/remote-pairing-verification'
|
||||
import { Badge } from '../ui/badge'
|
||||
import { Button } from '../ui/button'
|
||||
import { Checkbox } from '../ui/checkbox'
|
||||
@@ -10,14 +9,12 @@ import { Label } from '../ui/label'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
translateHostAccessLinkError,
|
||||
translateRemotePairingEndpointKind,
|
||||
translateRemotePairingFailureDescription
|
||||
translateRemotePairingEndpointKind
|
||||
} from '@/lib/remote-pairing-copy'
|
||||
|
||||
export type RuntimeHostAccessFailure = {
|
||||
kind: RemotePairingFailureKind
|
||||
message: string
|
||||
}
|
||||
import {
|
||||
RuntimeHostAccessFailureAlert,
|
||||
type RuntimeHostAccessFailure
|
||||
} from './RuntimeHostAccessFailureAlert'
|
||||
|
||||
type RuntimeHostAccessFormProps = {
|
||||
name: string
|
||||
@@ -238,51 +235,11 @@ export function RuntimeHostAccessForm({
|
||||
) : null}
|
||||
|
||||
{failure ? (
|
||||
<div
|
||||
<RuntimeHostAccessFailureAlert
|
||||
id="runtime-server-verification-error"
|
||||
role="alert"
|
||||
className="space-y-1 rounded-md border border-destructive/50 p-3"
|
||||
>
|
||||
<div className="text-sm font-medium text-destructive">
|
||||
{failure.kind === 'host-identity-mismatch'
|
||||
? translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.identityMismatch',
|
||||
'The reached Orca host does not match this access link'
|
||||
)
|
||||
: failure.kind === 'access-link-invalid'
|
||||
? translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.invalidLink',
|
||||
'This access link is no longer valid'
|
||||
)
|
||||
: failure.kind === 'protocol-incompatible'
|
||||
? translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.incompatible',
|
||||
'Orca versions are not compatible'
|
||||
)
|
||||
: failure.kind === 'connection-interrupted'
|
||||
? translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.interrupted',
|
||||
'Connection interrupted'
|
||||
)
|
||||
: failure.kind === 'environment-save-failed'
|
||||
? translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.saveFailed',
|
||||
'Could not save the host'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.unavailable',
|
||||
'Host unavailable'
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{failure.kind === 'environment-save-failed'
|
||||
? failure.message
|
||||
: translateRemotePairingFailureDescription(
|
||||
failure.kind,
|
||||
parsed.ok ? parsed.value.displayEndpoint : null
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
failure={failure}
|
||||
endpoint={parsed.ok ? parsed.value.displayEndpoint : null}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<details className="group text-xs">
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { Loader2, Link2 } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { parseHostAccessLink } from '../../../../shared/remote-pairing-address'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { translateHostAccessLinkError } from '@/lib/remote-pairing-copy'
|
||||
import { refreshRuntimeEnvironmentCatalog } from '@/runtime/runtime-environment-pairing-refresh'
|
||||
import { clearRuntimeCompatibilityCache } from '@/runtime/runtime-rpc-client'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
RuntimeHostAccessFailureAlert,
|
||||
type RuntimeHostAccessFailure
|
||||
} from './RuntimeHostAccessFailureAlert'
|
||||
|
||||
type RuntimeRepairDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
environmentId: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/** Pastes a fresh pairing link into a saved server whose pairing it revoked. */
|
||||
export function RuntimeRepairDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
environmentId,
|
||||
name
|
||||
}: RuntimeRepairDialogProps): React.JSX.Element {
|
||||
const [busy, setBusy] = useState(false)
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !busy && onOpenChange(next)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
{/* Why inside the content: it unmounts on close, so a reopen starts from an empty link. */}
|
||||
<RuntimeRepairForm
|
||||
environmentId={environmentId}
|
||||
name={name}
|
||||
busy={busy}
|
||||
setBusy={setBusy}
|
||||
onClose={() => onOpenChange(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function RuntimeRepairForm({
|
||||
environmentId,
|
||||
name,
|
||||
busy,
|
||||
setBusy,
|
||||
onClose
|
||||
}: {
|
||||
environmentId: string
|
||||
name: string
|
||||
busy: boolean
|
||||
setBusy: (busy: boolean) => void
|
||||
onClose: () => void
|
||||
}): React.JSX.Element {
|
||||
const [accessLink, setAccessLink] = useState('')
|
||||
const [failure, setFailure] = useState<RuntimeHostAccessFailure | null>(null)
|
||||
const mountedRef = useMountedRef()
|
||||
const parsed = useMemo(() => parseHostAccessLink(accessLink), [accessLink])
|
||||
const inputError = accessLink.trim() !== '' && !parsed.ok
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
if (!parsed.ok || busy) {
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setFailure(null)
|
||||
try {
|
||||
const result = await window.api.runtimeEnvironments.repairFromPairingCode({
|
||||
selector: environmentId,
|
||||
pairingCode: accessLink.trim()
|
||||
})
|
||||
if (!result.ok) {
|
||||
if (mountedRef.current) {
|
||||
setFailure({ kind: result.kind, message: result.message })
|
||||
}
|
||||
return
|
||||
}
|
||||
clearRuntimeCompatibilityCache(environmentId)
|
||||
await refreshRuntimeEnvironmentCatalog()
|
||||
await useAppStore.getState().readRuntimeHostStatusSnapshots()
|
||||
toast.success(
|
||||
translate(
|
||||
'auto.components.settings.RuntimeRepairDialog.success',
|
||||
'Re-paired with {{name}}',
|
||||
{
|
||||
name
|
||||
}
|
||||
)
|
||||
)
|
||||
if (mountedRef.current) {
|
||||
onClose()
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: translate('auto.components.settings.RuntimeRepairDialog.failed', 'Re-pair failed')
|
||||
)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void submit()
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{translate('auto.components.settings.RuntimeRepairDialog.title', 'Re-pair {{name}}', {
|
||||
name
|
||||
})}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{translate(
|
||||
'auto.components.settings.RuntimeRepairDialog.description',
|
||||
'{{name}} no longer accepts this computer. On {{name}}, open Settings → Remote Orca Servers → Share this host, create a link for another Orca client, then paste it here.',
|
||||
{ name }
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="runtime-repair-access-link">
|
||||
{translate('auto.components.settings.RuntimeHostAccessForm.accessLink', 'Access link')}
|
||||
</Label>
|
||||
<Input
|
||||
id="runtime-repair-access-link"
|
||||
autoFocus
|
||||
value={accessLink}
|
||||
disabled={busy}
|
||||
aria-invalid={inputError || failure !== null}
|
||||
aria-describedby={
|
||||
failure
|
||||
? 'runtime-repair-error'
|
||||
: inputError
|
||||
? 'runtime-repair-access-link-error'
|
||||
: undefined
|
||||
}
|
||||
onChange={(event) => {
|
||||
setFailure(null)
|
||||
setAccessLink(event.target.value)
|
||||
}}
|
||||
placeholder={translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.accessLinkPlaceholder',
|
||||
'orca://pair?code=...'
|
||||
)}
|
||||
className="min-w-0 font-mono"
|
||||
/>
|
||||
{inputError && !parsed.ok ? (
|
||||
<p id="runtime-repair-access-link-error" className="text-xs text-destructive">
|
||||
{translateHostAccessLinkError(parsed.kind)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{failure ? (
|
||||
<RuntimeHostAccessFailureAlert
|
||||
id="runtime-repair-error"
|
||||
failure={failure}
|
||||
endpoint={parsed.ok ? parsed.value.displayEndpoint : null}
|
||||
/>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" disabled={busy} onClick={onClose}>
|
||||
{translate('auto.components.settings.RuntimeHostAccessForm.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={!parsed.ok || busy}>
|
||||
{busy ? <Loader2 className="animate-spin" /> : <Link2 />}
|
||||
{translate('auto.components.settings.RuntimeRepairDialog.submit', 'Re-pair')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { AlertTriangle, Loader2, Server, ServerOff, Trash2 } from 'lucide-react'
|
||||
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
|
||||
import { useState } from 'react'
|
||||
import { AlertTriangle, Link2, Loader2, Server, ServerOff, Trash2 } from 'lucide-react'
|
||||
import {
|
||||
isUserManagedRuntimeEnvironment,
|
||||
type PublicKnownRuntimeEnvironment
|
||||
} from '../../../../shared/runtime-environments'
|
||||
import type { RemoteServerUpdateEntry } from '@/runtime/remote-server-update-coordinator'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -25,6 +29,7 @@ import {
|
||||
getRemoteServerManualUpdateHelp,
|
||||
RemoteServerUpdateStatus
|
||||
} from './RemoteServerUpdateStatus'
|
||||
import { RuntimeRepairDialog } from './RuntimeRepairDialog'
|
||||
|
||||
type RuntimeServerRowProps = {
|
||||
environment: PublicKnownRuntimeEnvironment
|
||||
@@ -87,7 +92,21 @@ export function RuntimeServerRow({
|
||||
runtimeStatusEntry.remoteControl ?? runtimeStatusEntry.status?.remoteControl ?? null
|
||||
}
|
||||
: details
|
||||
const detailsDescription = getHostDetailsDescription(effectiveDetails)
|
||||
const [repairOpen, setRepairOpen] = useState(false)
|
||||
const snapshot = runtimeStatusEntry?.snapshot
|
||||
const accessRevoked =
|
||||
snapshot?.verification === 'blocked' &&
|
||||
!snapshot.retired &&
|
||||
snapshot.blockedReason === 'access-revoked'
|
||||
// Why: managed and cloud-workspace servers are paired by the service that owns them.
|
||||
const repairable = !environment.orcadDeployment && isUserManagedRuntimeEnvironment(environment)
|
||||
const detailsDescription = accessRevoked
|
||||
? translate(
|
||||
'auto.components.settings.RuntimeEnvironmentsPane.accessRevoked',
|
||||
'{{value0}} no longer accepts this computer. Re-pair it with a new access link.',
|
||||
{ value0: environment.name }
|
||||
)
|
||||
: getHostDetailsDescription(effectiveDetails)
|
||||
const connectionState =
|
||||
details?.status === 'loading' && !runtimeStatusEntry?.status
|
||||
? 'checking'
|
||||
@@ -119,7 +138,7 @@ export function RuntimeServerRow({
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{getRuntimeServerConnectionLabel(connectionState)}
|
||||
</span>
|
||||
{effectiveDetails?.compatibility?.kind === 'blocked' ? (
|
||||
{accessRevoked || effectiveDetails?.compatibility?.kind === 'blocked' ? (
|
||||
<AlertTriangle className="size-3.5 shrink-0 text-destructive" />
|
||||
) : effectiveDetails?.status === 'loading' ? (
|
||||
<Loader2 className="size-3.5 shrink-0 animate-spin text-muted-foreground" />
|
||||
@@ -145,7 +164,7 @@ export function RuntimeServerRow({
|
||||
<p
|
||||
className={cn(
|
||||
'mt-0.5 truncate text-xs',
|
||||
effectiveDetails?.compatibility?.kind === 'blocked'
|
||||
accessRevoked || effectiveDetails?.compatibility?.kind === 'blocked'
|
||||
? 'text-destructive'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
@@ -191,6 +210,18 @@ export function RuntimeServerRow({
|
||||
{translate('auto.components.settings.RuntimeEnvironmentsPane.updateServer', 'Update')}
|
||||
</Button>
|
||||
) : null}
|
||||
{accessRevoked && repairable ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => setRepairOpen(true)}
|
||||
disabled={actionBusy}
|
||||
>
|
||||
<Link2 />
|
||||
{translate('auto.components.settings.RuntimeEnvironmentsPane.repair', 'Re-pair…')}
|
||||
</Button>
|
||||
) : null}
|
||||
{isReachable ? (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -240,6 +271,12 @@ export function RuntimeServerRow({
|
||||
{removing ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
<RuntimeRepairDialog
|
||||
open={repairOpen}
|
||||
onOpenChange={setRepairOpen}
|
||||
environmentId={environment.id}
|
||||
name={environment.name}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ import { cn } from '@/lib/utils'
|
||||
import { getUpdateCheckClickOptions, getUpdateCheckHint } from '@/lib/update-check-click-options'
|
||||
import { Button } from '../ui/button'
|
||||
import type { RuntimeHostDetails } from './runtime-environment-host-details'
|
||||
import { RuntimeHostAccessForm, type RuntimeHostAccessFailure } from './RuntimeHostAccessForm'
|
||||
import { RuntimeHostAccessForm } from './RuntimeHostAccessForm'
|
||||
import type { RuntimeHostAccessFailure } from './RuntimeHostAccessFailureAlert'
|
||||
import { RuntimeServerRow } from './runtime-server-row'
|
||||
|
||||
type RuntimeServersConnectSectionProps = {
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
|
||||
import type { RuntimeStatus } from '../../../../shared/runtime-types'
|
||||
import { isRuntimeEnvironmentRemovalBlocked } from './runtime-environment-host-details'
|
||||
import type { RuntimeHostAccessFailure } from './RuntimeHostAccessForm'
|
||||
import type { RuntimeHostAccessFailure } from './RuntimeHostAccessFailureAlert'
|
||||
|
||||
type RuntimeEnvironmentMutationActionParams = {
|
||||
environments: PublicKnownRuntimeEnvironment[]
|
||||
|
||||
@@ -34,7 +34,7 @@ function getHostStatusDetail(host: SidebarHostOption): string {
|
||||
if (host.compatibility?.kind === 'blocked') {
|
||||
return describeRuntimeCompatBlock(host.compatibility)
|
||||
}
|
||||
return `${getSidebarHostHealthLabel(host.health)}${host.detail ? ` - ${host.detail}` : ''}`
|
||||
return `${getSidebarHostHealthLabel(host.health, host.blockedReason)}${host.detail ? ` - ${host.detail}` : ''}`
|
||||
}
|
||||
|
||||
export function AddRepoHostSelector({
|
||||
@@ -79,7 +79,7 @@ export function AddRepoHostSelector({
|
||||
title={getHostStatusDetail(selectedHost)}
|
||||
className="shrink-0 text-[11px] font-normal text-muted-foreground"
|
||||
>
|
||||
{getSidebarHostHealthLabel(selectedHost.health)}
|
||||
{getSidebarHostHealthLabel(selectedHost.health, selectedHost.blockedReason)}
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronsUpDown className="size-3.5 shrink-0 opacity-50" />
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useCallback, useState } from 'react'
|
||||
import {
|
||||
AlertTriangle,
|
||||
Ellipsis,
|
||||
Link2,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Plug,
|
||||
@@ -34,6 +35,7 @@ import type { HostHeaderRow } from '../../../../shared/sidebar-list/host-section
|
||||
import { buildHostHeaderMenuModel } from './host-header-menu-items'
|
||||
import { HostRenameDialog } from './HostRenameDialog'
|
||||
import { HostRemoveDialog } from './HostRemoveDialog'
|
||||
import { RuntimeRepairDialog } from '../settings/RuntimeRepairDialog'
|
||||
import { resolveHostRemoval } from './host-rename-remove'
|
||||
|
||||
function blockedTitle(reason: 'client-too-old' | 'server-too-old'): string {
|
||||
@@ -72,6 +74,7 @@ export function HostSectionHeaderMenu({ row }: { row: HostHeaderRow }): React.JS
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [renameOpen, setRenameOpen] = useState(false)
|
||||
const [removeOpen, setRemoveOpen] = useState(false)
|
||||
const [repairOpen, setRepairOpen] = useState(false)
|
||||
const mountedRef = useMountedRef()
|
||||
const sshStatus = useAppStore((s) => {
|
||||
const parsed = parseExecutionHostId(row.hostId)
|
||||
@@ -85,9 +88,12 @@ export function HostSectionHeaderMenu({ row }: { row: HostHeaderRow }): React.JS
|
||||
kind: row.kind,
|
||||
health: row.health,
|
||||
sshConnected: sshStatus === 'connected',
|
||||
compatibility: row.compatibility
|
||||
compatibility: row.compatibility,
|
||||
blockedReason: row.blockedReason
|
||||
})
|
||||
const removalTarget = resolveHostRemoval(row.hostId)
|
||||
const parsedHost = parseExecutionHostId(row.hostId)
|
||||
const runtimeEnvironmentId = parsedHost?.kind === 'runtime' ? parsedHost.environmentId : null
|
||||
|
||||
const handleManage = useCallback(() => {
|
||||
openManageHost(row)
|
||||
@@ -204,6 +210,18 @@ export function HostSectionHeaderMenu({ row }: { row: HostHeaderRow }): React.JS
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent side="right" align="start" sideOffset={8} className="w-56">
|
||||
{model.actions.includes('runtime-repair') && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onSelect={() => setRepairOpen(true)}
|
||||
>
|
||||
<Link2 className="size-3.5" />
|
||||
{translate('auto.components.sidebar.HostSectionHeaderMenu.repair', 'Re-pair…')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{model.blocked && (
|
||||
<>
|
||||
<Tooltip>
|
||||
@@ -280,6 +298,14 @@ export function HostSectionHeaderMenu({ row }: { row: HostHeaderRow }): React.JS
|
||||
hostId={row.hostId}
|
||||
derivedLabel={row.label}
|
||||
/>
|
||||
{runtimeEnvironmentId && (
|
||||
<RuntimeRepairDialog
|
||||
open={repairOpen}
|
||||
onOpenChange={setRepairOpen}
|
||||
environmentId={runtimeEnvironmentId}
|
||||
name={row.label}
|
||||
/>
|
||||
)}
|
||||
{removalTarget && (
|
||||
<HostRemoveDialog
|
||||
open={removeOpen}
|
||||
|
||||
@@ -29,7 +29,7 @@ type SidebarHostScopeMenuSectionProps = {
|
||||
}
|
||||
|
||||
function getHostMetadata(host: SidebarHostOption): string {
|
||||
const healthLabel = getSidebarHostHealthLabel(host.health)
|
||||
const healthLabel = getSidebarHostHealthLabel(host.health, host.blockedReason)
|
||||
if (host.kind === 'local') {
|
||||
return host.detail
|
||||
}
|
||||
|
||||
@@ -89,4 +89,44 @@ describe('buildHostHeaderMenuModel', () => {
|
||||
})
|
||||
expect(model.blocked).toBeNull()
|
||||
})
|
||||
|
||||
it('leads with Re-pair for a runtime host that revoked this client', () => {
|
||||
const model = buildHostHeaderMenuModel({
|
||||
kind: 'runtime',
|
||||
health: 'blocked',
|
||||
blockedReason: 'access-revoked'
|
||||
})
|
||||
expect(model.actions).toEqual([
|
||||
'runtime-repair',
|
||||
'rename',
|
||||
'runtime-check-connection',
|
||||
'manage',
|
||||
'remove'
|
||||
])
|
||||
expect(model.blocked).toBeNull()
|
||||
})
|
||||
|
||||
it('offers Re-pair only for an access-revoked runtime host', () => {
|
||||
expect(
|
||||
buildHostHeaderMenuModel({
|
||||
kind: 'runtime',
|
||||
health: 'blocked',
|
||||
blockedReason: 'incompatible'
|
||||
}).actions
|
||||
).not.toContain('runtime-repair')
|
||||
expect(
|
||||
buildHostHeaderMenuModel({
|
||||
kind: 'runtime',
|
||||
health: 'available',
|
||||
blockedReason: 'access-revoked'
|
||||
}).actions
|
||||
).not.toContain('runtime-repair')
|
||||
expect(
|
||||
buildHostHeaderMenuModel({
|
||||
kind: 'ssh',
|
||||
health: 'blocked',
|
||||
blockedReason: 'access-revoked'
|
||||
}).actions
|
||||
).not.toContain('runtime-repair')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ExecutionHostKind } from '../../../../shared/execution-host'
|
||||
import type { ExecutionHostHealth } from '../../../../shared/execution-host-registry'
|
||||
import type { RuntimeCompatVerdict } from '../../../../shared/protocol-compat'
|
||||
import type { RuntimeHostBlockedReason } from '../../../../shared/runtime-host-status'
|
||||
|
||||
// Why: the host-header dropdown shows different lifecycle actions per host kind.
|
||||
// Keeping the availability rules in a pure function makes them unit-testable
|
||||
@@ -9,6 +10,7 @@ import type { RuntimeCompatVerdict } from '../../../../shared/protocol-compat'
|
||||
// control (the design doc forbids a separate focused-host toggle), and
|
||||
// decluttering is served by collapsing the section.
|
||||
export type HostHeaderMenuAction =
|
||||
| 'runtime-repair'
|
||||
| 'rename'
|
||||
| 'manage'
|
||||
| 'ssh-reconnect'
|
||||
@@ -31,6 +33,7 @@ export type HostHeaderMenuInput = {
|
||||
/** SSH connection status drives Reconnect vs Disconnect. */
|
||||
sshConnected?: boolean
|
||||
compatibility?: RuntimeCompatVerdict
|
||||
blockedReason?: RuntimeHostBlockedReason
|
||||
}
|
||||
|
||||
function sshActions(connected: boolean): HostHeaderMenuAction[] {
|
||||
@@ -40,9 +43,18 @@ function sshActions(connected: boolean): HostHeaderMenuAction[] {
|
||||
}
|
||||
|
||||
export function buildHostHeaderMenuModel(input: HostHeaderMenuInput): HostHeaderMenuModel {
|
||||
const actions: HostHeaderMenuAction[] = []
|
||||
// Why first: a revoked pairing is the one fix the user came to the menu for.
|
||||
if (
|
||||
input.kind === 'runtime' &&
|
||||
input.health === 'blocked' &&
|
||||
input.blockedReason === 'access-revoked'
|
||||
) {
|
||||
actions.push('runtime-repair')
|
||||
}
|
||||
// Why: Rename edits only the client-side display label, so it's offered for
|
||||
// every host kind including local.
|
||||
const actions: HostHeaderMenuAction[] = ['rename']
|
||||
actions.push('rename')
|
||||
|
||||
switch (input.kind) {
|
||||
case 'ssh':
|
||||
@@ -65,7 +77,9 @@ export function buildHostHeaderMenuModel(input: HostHeaderMenuInput): HostHeader
|
||||
}
|
||||
|
||||
const blocked =
|
||||
input.health === 'blocked' && input.compatibility?.kind === 'blocked'
|
||||
input.health === 'blocked' &&
|
||||
input.blockedReason !== 'access-revoked' &&
|
||||
input.compatibility?.kind === 'blocked'
|
||||
? { reason: input.compatibility.reason }
|
||||
: null
|
||||
|
||||
|
||||
@@ -684,7 +684,7 @@ describe('addHostSectionRows', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('passes host kind and blocked compatibility through to the header row', () => {
|
||||
it('passes host kind, blocked compatibility and reason through to the header row', () => {
|
||||
const localOwned: Repo = { ...repo('local-project'), executionHostId: 'local' }
|
||||
const project = repo('runtime-project')
|
||||
const rows = [
|
||||
@@ -716,7 +716,8 @@ describe('addHostSectionRows', () => {
|
||||
clientProtocolVersion: 5,
|
||||
serverProtocolVersion: 1,
|
||||
requiredServerProtocolVersion: 4
|
||||
}
|
||||
},
|
||||
blockedReason: 'incompatible'
|
||||
}
|
||||
],
|
||||
workspaceHostScope: 'all',
|
||||
@@ -728,7 +729,8 @@ describe('addHostSectionRows', () => {
|
||||
).toMatchObject({
|
||||
kind: 'runtime',
|
||||
health: 'blocked',
|
||||
compatibility: { kind: 'blocked', reason: 'server-too-old' }
|
||||
compatibility: { kind: 'blocked', reason: 'server-too-old' },
|
||||
blockedReason: 'incompatible'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -217,6 +217,8 @@ describe('sidebar host options', () => {
|
||||
expect(getSidebarHostHealthLabel('available')).toBe('Connected')
|
||||
expect(getSidebarHostHealthLabel('connecting')).toBe('Connecting')
|
||||
expect(getSidebarHostHealthLabel('blocked')).toBe('Update needed')
|
||||
expect(getSidebarHostHealthLabel('blocked', 'incompatible')).toBe('Update needed')
|
||||
expect(getSidebarHostHealthLabel('blocked', 'access-revoked')).toBe('Re-pair needed')
|
||||
expect(getSidebarHostHealthLabel('error')).toBe('Needs attention')
|
||||
})
|
||||
|
||||
|
||||
@@ -20,7 +20,10 @@ import {
|
||||
isWslHostRuntimeEnvironment,
|
||||
type PublicKnownRuntimeEnvironment
|
||||
} from '../../../../shared/runtime-environments'
|
||||
import type { RuntimeEnvironmentStatus } from '../../../../shared/runtime-host-status'
|
||||
import type {
|
||||
RuntimeEnvironmentStatus,
|
||||
RuntimeHostBlockedReason
|
||||
} from '../../../../shared/runtime-host-status'
|
||||
import type { WslHostStatus } from '../../../../shared/wsl-host-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
@@ -39,6 +42,7 @@ export type SidebarHostOption = {
|
||||
presence: 'local' | 'configured' | 'project' | 'active'
|
||||
// Why: surfaced to the sidebar host-header menu so it can warn on version skew.
|
||||
compatibility?: RuntimeCompatVerdict
|
||||
blockedReason?: RuntimeHostBlockedReason
|
||||
// Why: lets host headers spell out auth-needed SSH states, not just an icon.
|
||||
connectionStatus?: SshConnectionStatus
|
||||
aliasHostIds?: readonly ExecutionHostId[]
|
||||
@@ -200,6 +204,9 @@ export function getSidebarHostVisibilityLabel(
|
||||
)
|
||||
}
|
||||
|
||||
export function getSidebarHostHealthLabel(health: SidebarHostScopeOption['health']): string {
|
||||
return health === 'mixed' ? 'Mixed' : getExecutionHostHealthLabel(health)
|
||||
export function getSidebarHostHealthLabel(
|
||||
health: SidebarHostScopeOption['health'],
|
||||
blockedReason?: RuntimeHostBlockedReason
|
||||
): string {
|
||||
return health === 'mixed' ? 'Mixed' : getExecutionHostHealthLabel(health, blockedReason)
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ import React from 'react'
|
||||
import { AlertTriangle, ChevronDown, Loader2, Server, ServerOff } from 'lucide-react'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { HostHeaderRow } from '../../../../../../shared/sidebar-list/host-section-rows'
|
||||
import { HostSectionHeaderMenu } from '../../HostSectionHeaderMenu'
|
||||
import { stopRepoHeaderMenuEvent } from './header-event-guards'
|
||||
import { getHostHeaderDetail } from './host-header-detail'
|
||||
|
||||
function formatSectionActivityLabel(count: number, label: string): string {
|
||||
return `${count} ${label}${count === 1 ? '' : 's'}`
|
||||
@@ -47,37 +48,6 @@ function HostHeaderHealthIcon({
|
||||
return null
|
||||
}
|
||||
|
||||
function getHostHeaderDetail(row: HostHeaderRow): { text: string; isWarning: boolean } | null {
|
||||
// Why: a blocked compatibility verdict earns a compact warning so the host stands out.
|
||||
if (row.health === 'blocked') {
|
||||
return {
|
||||
text: translate('auto.components.sidebar.WorktreeList.7a8b9c0d1e', 'Update required'),
|
||||
isWarning: true
|
||||
}
|
||||
}
|
||||
// Why: auth-failed needs a worded status; the health icon alone doesn't tell the user to re-auth.
|
||||
if (row.connectionStatus === 'auth-failed') {
|
||||
return {
|
||||
text: translate(
|
||||
'auto.components.sidebar.WorktreeList.hostAuthNeeded',
|
||||
'Authentication needed'
|
||||
),
|
||||
isWarning: true
|
||||
}
|
||||
}
|
||||
if (row.health === 'disconnected') {
|
||||
return {
|
||||
text: translate('auto.components.sidebar.WorktreeList.hostDisconnected', 'Disconnected'),
|
||||
isWarning: false
|
||||
}
|
||||
}
|
||||
// Why: show the transport detail only for remote hosts; it's noise under the local label.
|
||||
if (row.kind !== 'local') {
|
||||
return { text: row.detail, isWarning: false }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function HostSectionHeader({
|
||||
row,
|
||||
onToggle,
|
||||
@@ -126,7 +96,7 @@ export function HostSectionHeader({
|
||||
)}
|
||||
<HostHeaderHealthIcon health={row.health} />
|
||||
{/* Why: badge hugs the label (like repo headers) instead of floating by the hover controls. */}
|
||||
<div className="flex min-w-0 flex-1 items-baseline gap-1.5">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 truncate text-[12px] font-semibold leading-none',
|
||||
@@ -152,7 +122,17 @@ export function HostSectionHeader({
|
||||
className={cn('size-3.5 transition-transform', row.collapsed && '-rotate-90')}
|
||||
/>
|
||||
</div>
|
||||
<span data-host-header-action="">
|
||||
{/* Why: menu and dialog portals still bubble React events here; keep them from toggling or dragging the row. */}
|
||||
<span
|
||||
data-host-header-action=""
|
||||
className="flex shrink-0 items-center"
|
||||
onPointerDown={stopRepoHeaderMenuEvent}
|
||||
onMouseDown={stopRepoHeaderMenuEvent}
|
||||
onPointerUp={stopRepoHeaderMenuEvent}
|
||||
onMouseUp={stopRepoHeaderMenuEvent}
|
||||
onClick={stopRepoHeaderMenuEvent}
|
||||
onKeyDown={stopRepoHeaderMenuEvent}
|
||||
>
|
||||
<HostSectionHeaderMenu row={row} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getHostHeaderDetail } from './host-header-detail'
|
||||
|
||||
const runtimeRow = { kind: 'runtime', detail: 'Orca server' } as const
|
||||
|
||||
describe('getHostHeaderDetail', () => {
|
||||
it('asks for a re-pair when the host revoked this client', () => {
|
||||
expect(
|
||||
getHostHeaderDetail({
|
||||
...runtimeRow,
|
||||
health: 'blocked',
|
||||
blockedReason: 'access-revoked'
|
||||
})
|
||||
).toEqual({ text: 'Re-pair required', isWarning: true })
|
||||
})
|
||||
|
||||
it('keeps the update label for an incompatible or unlabeled block', () => {
|
||||
expect(
|
||||
getHostHeaderDetail({
|
||||
...runtimeRow,
|
||||
health: 'blocked',
|
||||
blockedReason: 'incompatible'
|
||||
})
|
||||
).toEqual({ text: 'Update required', isWarning: true })
|
||||
expect(getHostHeaderDetail({ ...runtimeRow, health: 'blocked' })).toEqual({
|
||||
text: 'Update required',
|
||||
isWarning: true
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { HostHeaderRow } from '../../../../../../shared/sidebar-list/host-section-rows'
|
||||
|
||||
export function getHostHeaderDetail(
|
||||
row: Pick<HostHeaderRow, 'health' | 'blockedReason' | 'connectionStatus' | 'kind' | 'detail'>
|
||||
): { text: string; isWarning: boolean } | null {
|
||||
// Why: a host that revoked this client's pairing needs a new link, not an update.
|
||||
if (row.health === 'blocked' && row.blockedReason === 'access-revoked') {
|
||||
return {
|
||||
text: translate(
|
||||
'auto.components.sidebar.WorktreeList.hostRepairRequired',
|
||||
'Re-pair required'
|
||||
),
|
||||
isWarning: true
|
||||
}
|
||||
}
|
||||
// Why: a blocked compatibility verdict earns a compact warning so the host stands out.
|
||||
if (row.health === 'blocked') {
|
||||
return {
|
||||
text: translate('auto.components.sidebar.WorktreeList.7a8b9c0d1e', 'Update required'),
|
||||
isWarning: true
|
||||
}
|
||||
}
|
||||
// Why: auth-failed needs a worded status; the health icon alone doesn't tell the user to re-auth.
|
||||
if (row.connectionStatus === 'auth-failed') {
|
||||
return {
|
||||
text: translate(
|
||||
'auto.components.sidebar.WorktreeList.hostAuthNeeded',
|
||||
'Authentication needed'
|
||||
),
|
||||
isWarning: true
|
||||
}
|
||||
}
|
||||
if (row.health === 'disconnected') {
|
||||
return {
|
||||
text: translate('auto.components.sidebar.WorktreeList.hostDisconnected', 'Disconnected'),
|
||||
isWarning: false
|
||||
}
|
||||
}
|
||||
// Why: show the transport detail only for remote hosts; it's noise under the local label.
|
||||
if (row.kind !== 'local') {
|
||||
return { text: row.detail, isWarning: false }
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -567,7 +567,8 @@
|
||||
"loopbackPairingBlocked": "This access link points back to this device.",
|
||||
"remotePairingUnreachable": "Cannot reach Orca at {{endpoint}}.",
|
||||
"remotePairingInvalidDetails": "This access link contains invalid connection details.",
|
||||
"remotePairingSaveFailed": "Orca verified the host but could not save it. Check browser storage and try again."
|
||||
"remotePairingSaveFailed": "Orca verified the host but could not save it. Check browser storage and try again.",
|
||||
"remotePairingRepairUnavailable": "Re-pairing is not available in the browser. Pair this browser with the server again."
|
||||
},
|
||||
"web": {
|
||||
"preload": {
|
||||
@@ -6139,7 +6140,8 @@
|
||||
"groupRenameFailedDesc": "Orca could not confirm the new name with the group's host. Recheck the group after reconnecting.",
|
||||
"failedNestWorkspace": "Failed to nest workspace",
|
||||
"sidebarRowMissing": "Target no longer exists",
|
||||
"failedUnnestWorkspace": "Failed to unnest workspace"
|
||||
"failedUnnestWorkspace": "Failed to unnest workspace",
|
||||
"hostRepairRequired": "Re-pair required"
|
||||
},
|
||||
"WorktreeMetaDialog": {
|
||||
"3db0a2a593": "Cancel",
|
||||
@@ -6466,7 +6468,8 @@
|
||||
"59b553e2aa": "Disconnect",
|
||||
"2d3e4f5a6b": "Check connection",
|
||||
"3c4d5e6f7a": "Manage host…",
|
||||
"6e7f8a9b0c": "Remove host…"
|
||||
"6e7f8a9b0c": "Remove host…",
|
||||
"repair": "Re-pair…"
|
||||
},
|
||||
"LinearAgentSkillSetupPrompt": {
|
||||
"modalTitle": "Enable Linear ticket access",
|
||||
@@ -8675,7 +8678,9 @@
|
||||
"troubleshootStepRegenerate": "Generate a new access link and use only the newest link here.",
|
||||
"troubleshootTunnel": "Using an SSH local forward? Return to Connect to a host, paste the loopback link, then enable “I am using an SSH tunnel” under Advanced.",
|
||||
"cloudVmWorkflow": "Cloud VM",
|
||||
"cloudVmWorkflowHelp": "Manage recipe-created cloud machines"
|
||||
"cloudVmWorkflowHelp": "Manage recipe-created cloud machines",
|
||||
"accessRevoked": "{{value0}} no longer accepts this computer. Re-pair it with a new access link.",
|
||||
"repair": "Re-pair…"
|
||||
},
|
||||
"RuntimePairingGeneratedUrlRows": {
|
||||
"0495f68959": "Copy {{value0}}"
|
||||
@@ -12320,7 +12325,9 @@
|
||||
"networkConnection": "Network connection",
|
||||
"notAttempted": "Not attempted",
|
||||
"saveFailed": "Could not save the host",
|
||||
"saveFailedHelp": "The host was verified, but Orca could not save it. Check the name and local settings storage, then try again."
|
||||
"saveFailedHelp": "The host was verified, but Orca could not save it. Check the name and local settings storage, then try again.",
|
||||
"differentServer": "This link is for a different server",
|
||||
"differentServerHelp": "This pairing link is for a different server. Create the link on the server you are re-pairing."
|
||||
},
|
||||
"CloudVmSetupGuide": {
|
||||
"title": "Create a Cloud VM",
|
||||
@@ -12908,6 +12915,13 @@
|
||||
"confirm": "Restart",
|
||||
"terminalsUnknown": "Orca couldn’t confirm whether terminals are running on this host, so it didn’t restart the managed server.",
|
||||
"terminalsRunning": "{{count}} terminals are still running on this host. Close them, then restart the managed server."
|
||||
},
|
||||
"RuntimeRepairDialog": {
|
||||
"title": "Re-pair {{name}}",
|
||||
"description": "{{name}} no longer accepts this computer. On {{name}}, open Settings → Remote Orca Servers → Share this host, create a link for another Orca client, then paste it here.",
|
||||
"submit": "Re-pair",
|
||||
"success": "Re-paired with {{name}}",
|
||||
"failed": "Re-pair failed"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
|
||||
@@ -387,7 +387,8 @@
|
||||
"loopbackPairingBlocked": "Este enlace de acceso apunta de nuevo a este dispositivo.",
|
||||
"remotePairingUnreachable": "No se puede acceder a Orca en {{endpoint}}.",
|
||||
"remotePairingInvalidDetails": "Este enlace de acceso contiene datos de conexión no válidos.",
|
||||
"remotePairingSaveFailed": "Orca verificó al anfitrión, pero no pudo guardarlo. Compruebe el almacenamiento del navegador e inténtelo de nuevo."
|
||||
"remotePairingSaveFailed": "Orca verificó al anfitrión, pero no pudo guardarlo. Compruebe el almacenamiento del navegador e inténtelo de nuevo.",
|
||||
"remotePairingRepairUnavailable": "Re-pairing is not available in the browser. Pair this browser with the server again."
|
||||
},
|
||||
"web": {
|
||||
"preload": {
|
||||
@@ -5866,7 +5867,8 @@
|
||||
"sidebarRowMissing": "El destino ya no existe",
|
||||
"failedUnnestWorkspace": "Error al expandir el espacio de trabajo",
|
||||
"groupRenameFailed": "No se pudo cambiar el nombre del grupo",
|
||||
"groupRenameFailedDesc": "Orca no pudo confirmar el nuevo nombre con el anfitrión del grupo. Vuelva a verificar el grupo después de volver a conectarse."
|
||||
"groupRenameFailedDesc": "Orca no pudo confirmar el nuevo nombre con el anfitrión del grupo. Vuelva a verificar el grupo después de volver a conectarse.",
|
||||
"hostRepairRequired": "Re-pair required"
|
||||
},
|
||||
"WorktreeMetaDialog": {
|
||||
"3db0a2a593": "Cancelar",
|
||||
@@ -6290,7 +6292,8 @@
|
||||
"59b553e2aa": "Desconectar",
|
||||
"2d3e4f5a6b": "Verificar conexión",
|
||||
"3c4d5e6f7a": "Administrar host…",
|
||||
"6e7f8a9b0c": "Quitar host…"
|
||||
"6e7f8a9b0c": "Quitar host…",
|
||||
"repair": "Re-pair…"
|
||||
},
|
||||
"WorkspaceKanbanDrawer": {
|
||||
"1975a4e480": "Falló la sincronización del estado de la tarea",
|
||||
@@ -8383,7 +8386,9 @@
|
||||
"serverWorkspaceWindowClosed": "Ventana del espacio de trabajo cerrada",
|
||||
"serverRuntimeUnavailable": "Orca no disponible",
|
||||
"serverRuntimeUnavailableDescription": "El transporte SSH está conectado, pero el tiempo de ejecución de Orca no respondió. Es posible que el host todavía esté ejecutándose.",
|
||||
"serverReconnecting": "Reconectando"
|
||||
"serverReconnecting": "Reconectando",
|
||||
"accessRevoked": "{{value0}} no longer accepts this computer. Re-pair it with a new access link.",
|
||||
"repair": "Re-pair…"
|
||||
},
|
||||
"RuntimePairingGeneratedUrlRows": {
|
||||
"0495f68959": "Copiar {{value0}}"
|
||||
@@ -12074,7 +12079,9 @@
|
||||
"networkConnection": "Conexión de red",
|
||||
"notAttempted": "No se intentó",
|
||||
"saveFailed": "No se pudo guardar el host",
|
||||
"saveFailedHelp": "El anfitrión fue verificado, pero Orca no pudo salvarlo. Verifique el nombre y el almacenamiento de la configuración local y vuelva a intentarlo."
|
||||
"saveFailedHelp": "El anfitrión fue verificado, pero Orca no pudo salvarlo. Verifique el nombre y el almacenamiento de la configuración local y vuelva a intentarlo.",
|
||||
"differentServer": "This link is for a different server",
|
||||
"differentServerHelp": "This pairing link is for a different server. Create the link on the server you are re-pairing."
|
||||
},
|
||||
"CloudVmSetupGuide": {
|
||||
"title": "Crear una VM en la nube",
|
||||
@@ -12644,6 +12651,13 @@
|
||||
"remove": "Quitar",
|
||||
"turnOn": "Activar en Experimental",
|
||||
"forceUpdate": "Forzar actualización"
|
||||
},
|
||||
"RuntimeRepairDialog": {
|
||||
"title": "Re-pair {{name}}",
|
||||
"description": "{{name}} no longer accepts this computer. On {{name}}, open Settings → Remote Orca Servers → Share this host, create a link for another Orca client, then paste it here.",
|
||||
"submit": "Re-pair",
|
||||
"success": "Re-paired with {{name}}",
|
||||
"failed": "Re-pair failed"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
|
||||
@@ -491,7 +491,8 @@
|
||||
"loopbackPairingBlocked": "Ce lien d'accès pointe vers cet appareil lui-même.",
|
||||
"remotePairingUnreachable": "Impossible de joindre Orca à l'adresse {{endpoint}}.",
|
||||
"remotePairingInvalidDetails": "Ce lien d'accès contient des détails de connexion invalides.",
|
||||
"remotePairingSaveFailed": "Orca a vérifié l'hôte mais n'a pas pu l'enregistrer. Vérifiez le stockage du navigateur et réessayez."
|
||||
"remotePairingSaveFailed": "Orca a vérifié l'hôte mais n'a pas pu l'enregistrer. Vérifiez le stockage du navigateur et réessayez.",
|
||||
"remotePairingRepairUnavailable": "Re-pairing is not available in the browser. Pair this browser with the server again."
|
||||
},
|
||||
"web": {
|
||||
"preload": {
|
||||
@@ -6006,7 +6007,8 @@
|
||||
"groupRenameFailedDesc": "Orca n'a pas pu confirmer le nouveau nom auprès de l'hôte du groupe. Revérifiez le groupe après reconnexion.",
|
||||
"failedNestWorkspace": "Échec de l'imbrication de l'espace de travail",
|
||||
"sidebarRowMissing": "La cible n'existe plus",
|
||||
"failedUnnestWorkspace": "Échec de la désimbrication de l'espace de travail"
|
||||
"failedUnnestWorkspace": "Échec de la désimbrication de l'espace de travail",
|
||||
"hostRepairRequired": "Re-pair required"
|
||||
},
|
||||
"WorktreeMetaDialog": {
|
||||
"3db0a2a593": "Annuler",
|
||||
@@ -6332,7 +6334,8 @@
|
||||
"59b553e2aa": "Déconnecter",
|
||||
"2d3e4f5a6b": "Vérifier la connexion",
|
||||
"3c4d5e6f7a": "Gérer l'hôte…",
|
||||
"6e7f8a9b0c": "Retirer l'hôte…"
|
||||
"6e7f8a9b0c": "Retirer l'hôte…",
|
||||
"repair": "Re-pair…"
|
||||
},
|
||||
"LinearAgentSkillSetupPrompt": {
|
||||
"modalTitle": "Activer l'accès aux tickets Linear",
|
||||
@@ -8534,7 +8537,9 @@
|
||||
"serverWorkspaceWindowClosed": "Fenêtre de l'espace de travail fermée",
|
||||
"serverRuntimeUnavailable": "Orque indisponible",
|
||||
"serverRuntimeUnavailableDescription": "Le transport SSH est connecté, mais le runtime Orca n'a pas répondu. L'hôte est peut-être toujours en cours d'exécution.",
|
||||
"serverReconnecting": "Reconnexion"
|
||||
"serverReconnecting": "Reconnexion",
|
||||
"accessRevoked": "{{value0}} no longer accepts this computer. Re-pair it with a new access link.",
|
||||
"repair": "Re-pair…"
|
||||
},
|
||||
"RuntimePairingGeneratedUrlRows": {
|
||||
"0495f68959": "Copier {{value0}}"
|
||||
@@ -12178,7 +12183,9 @@
|
||||
"networkConnection": "Connexion réseau",
|
||||
"notAttempted": "Non tenté",
|
||||
"saveFailed": "Impossible d'enregistrer l'hôte",
|
||||
"saveFailedHelp": "L'hôte a été vérifié, mais Orca n'a pas pu l'enregistrer. Vérifiez le nom et le stockage local des paramètres, puis réessayez."
|
||||
"saveFailedHelp": "L'hôte a été vérifié, mais Orca n'a pas pu l'enregistrer. Vérifiez le nom et le stockage local des paramètres, puis réessayez.",
|
||||
"differentServer": "This link is for a different server",
|
||||
"differentServerHelp": "This pairing link is for a different server. Create the link on the server you are re-pairing."
|
||||
},
|
||||
"CloudVmSetupGuide": {
|
||||
"title": "Créer une VM cloud",
|
||||
@@ -12757,6 +12764,13 @@
|
||||
"remove": "Supprimer",
|
||||
"turnOn": "Activer dans Expérimental",
|
||||
"forceUpdate": "Forcer la mise à jour"
|
||||
},
|
||||
"RuntimeRepairDialog": {
|
||||
"title": "Re-pair {{name}}",
|
||||
"description": "{{name}} no longer accepts this computer. On {{name}}, open Settings → Remote Orca Servers → Share this host, create a link for another Orca client, then paste it here.",
|
||||
"submit": "Re-pair",
|
||||
"success": "Re-paired with {{name}}",
|
||||
"failed": "Re-pair failed"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
|
||||
@@ -387,7 +387,8 @@
|
||||
"loopbackPairingBlocked": "このアクセスリンクはこのデバイス自身を指しています。",
|
||||
"remotePairingUnreachable": "{{endpoint}} の Orca に接続できません。",
|
||||
"remotePairingInvalidDetails": "このアクセスリンクには無効な接続情報が含まれています。",
|
||||
"remotePairingSaveFailed": "Orca はホストを確認しましたが、ホストを保存できませんでした。ブラウザのストレージを確認して、もう一度試してください。"
|
||||
"remotePairingSaveFailed": "Orca はホストを確認しましたが、ホストを保存できませんでした。ブラウザのストレージを確認して、もう一度試してください。",
|
||||
"remotePairingRepairUnavailable": "Re-pairing is not available in the browser. Pair this browser with the server again."
|
||||
},
|
||||
"web": {
|
||||
"preload": {
|
||||
@@ -5852,7 +5853,8 @@
|
||||
"sidebarRowMissing": "対象はもう存在しません",
|
||||
"failedUnnestWorkspace": "ワークスペースの展開に失敗しました",
|
||||
"groupRenameFailed": "グループ名の変更に失敗しました",
|
||||
"groupRenameFailedDesc": "Orca はグループのホストに新規名前を確認できませんでした。再接続後、グループを再確認してください。"
|
||||
"groupRenameFailedDesc": "Orca はグループのホストに新規名前を確認できませんでした。再接続後、グループを再確認してください。",
|
||||
"hostRepairRequired": "Re-pair required"
|
||||
},
|
||||
"WorktreeMetaDialog": {
|
||||
"3db0a2a593": "キャンセル",
|
||||
@@ -6295,7 +6297,8 @@
|
||||
"59b553e2aa": "切断",
|
||||
"2d3e4f5a6b": "接続を確認",
|
||||
"3c4d5e6f7a": "ホストを管理…",
|
||||
"6e7f8a9b0c": "ホストを削除…"
|
||||
"6e7f8a9b0c": "ホストを削除…",
|
||||
"repair": "Re-pair…"
|
||||
},
|
||||
"WorkspaceKanbanDrawer": {
|
||||
"1975a4e480": "タスクステータスの同期に失敗しました",
|
||||
@@ -8410,7 +8413,9 @@
|
||||
"serverWorkspaceWindowClosed": "ワークスペースウィンドウを閉じた",
|
||||
"serverRuntimeUnavailable": "Orca は利用できません",
|
||||
"serverRuntimeUnavailableDescription": "SSH トランスポートは接続されていますが、Orca ランタイムが応答しませんでした。ホストがまだ実行中の可能性があります。",
|
||||
"serverReconnecting": "再接続中"
|
||||
"serverReconnecting": "再接続中",
|
||||
"accessRevoked": "{{value0}} no longer accepts this computer. Re-pair it with a new access link.",
|
||||
"repair": "Re-pair…"
|
||||
},
|
||||
"RuntimePairingGeneratedUrlRows": {
|
||||
"0495f68959": "{{value0}}をコピー"
|
||||
@@ -12079,7 +12084,9 @@
|
||||
"networkConnection": "ネットワーク接続",
|
||||
"notAttempted": "未試行",
|
||||
"saveFailed": "ホストを保存できませんでした",
|
||||
"saveFailedHelp": "ホストは確認されましたが、Orca はホストを保存できませんでした。名前とローカル設定ストレージを確認して、再試行してください。"
|
||||
"saveFailedHelp": "ホストは確認されましたが、Orca はホストを保存できませんでした。名前とローカル設定ストレージを確認して、再試行してください。",
|
||||
"differentServer": "This link is for a different server",
|
||||
"differentServerHelp": "This pairing link is for a different server. Create the link on the server you are re-pairing."
|
||||
},
|
||||
"CloudVmSetupGuide": {
|
||||
"title": "クラウド VM を作成",
|
||||
@@ -12649,6 +12656,13 @@
|
||||
"remove": "削除",
|
||||
"turnOn": "実験的機能でオンにする",
|
||||
"forceUpdate": "強制更新"
|
||||
},
|
||||
"RuntimeRepairDialog": {
|
||||
"title": "Re-pair {{name}}",
|
||||
"description": "{{name}} no longer accepts this computer. On {{name}}, open Settings → Remote Orca Servers → Share this host, create a link for another Orca client, then paste it here.",
|
||||
"submit": "Re-pair",
|
||||
"success": "Re-paired with {{name}}",
|
||||
"failed": "Re-pair failed"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
|
||||
@@ -387,7 +387,8 @@
|
||||
"loopbackPairingBlocked": "이 액세스 링크는 이 기기 자체를 가리킵니다.",
|
||||
"remotePairingUnreachable": "{{endpoint}}의 Orca에 연결할 수 없습니다.",
|
||||
"remotePairingInvalidDetails": "이 액세스 링크에 잘못된 연결 정보가 포함되어 있습니다.",
|
||||
"remotePairingSaveFailed": "Orca가 호스트를 확인했지만 저장할 수 없습니다. 브라우저 저장 공간을 확인하고 다시 시도하세요."
|
||||
"remotePairingSaveFailed": "Orca가 호스트를 확인했지만 저장할 수 없습니다. 브라우저 저장 공간을 확인하고 다시 시도하세요.",
|
||||
"remotePairingRepairUnavailable": "Re-pairing is not available in the browser. Pair this browser with the server again."
|
||||
},
|
||||
"web": {
|
||||
"preload": {
|
||||
@@ -5852,7 +5853,8 @@
|
||||
"sidebarRowMissing": "대상이 더 이상 존재하지 않습니다",
|
||||
"failedUnnestWorkspace": "워크트리 펼치기 실패",
|
||||
"groupRenameFailed": "그룹 이름을 바꾸지 못했습니다.",
|
||||
"groupRenameFailedDesc": "Orca는 그룹 호스트에게 새 이름을 확인할 수 없습니다. 다시 연결한 후 그룹을 다시 확인하세요."
|
||||
"groupRenameFailedDesc": "Orca는 그룹 호스트에게 새 이름을 확인할 수 없습니다. 다시 연결한 후 그룹을 다시 확인하세요.",
|
||||
"hostRepairRequired": "Re-pair required"
|
||||
},
|
||||
"WorktreeMetaDialog": {
|
||||
"3db0a2a593": "취소",
|
||||
@@ -6295,7 +6297,8 @@
|
||||
"59b553e2aa": "연결 해제",
|
||||
"2d3e4f5a6b": "연결 확인",
|
||||
"3c4d5e6f7a": "호스트 관리…",
|
||||
"6e7f8a9b0c": "호스트 제거…"
|
||||
"6e7f8a9b0c": "호스트 제거…",
|
||||
"repair": "Re-pair…"
|
||||
},
|
||||
"WorkspaceKanbanDrawer": {
|
||||
"1975a4e480": "작업 상태 동기화 실패",
|
||||
@@ -8373,7 +8376,9 @@
|
||||
"serverWorkspaceWindowClosed": "워크스페이스 창이 닫혔습니다.",
|
||||
"serverRuntimeUnavailable": "Orca를 사용할 수 없습니다.",
|
||||
"serverRuntimeUnavailableDescription": "SSH 전송이 연결되었지만 Orca 런타임이 응답하지 않았습니다. 호스트가 아직 실행 중일 수 있습니다.",
|
||||
"serverReconnecting": "다시 연결하는 중"
|
||||
"serverReconnecting": "다시 연결하는 중",
|
||||
"accessRevoked": "{{value0}} no longer accepts this computer. Re-pair it with a new access link.",
|
||||
"repair": "Re-pair…"
|
||||
},
|
||||
"RuntimePairingGeneratedUrlRows": {
|
||||
"0495f68959": "{{value0}} 복사"
|
||||
@@ -12079,7 +12084,9 @@
|
||||
"networkConnection": "네트워크 연결",
|
||||
"notAttempted": "시도하지 않음",
|
||||
"saveFailed": "호스트를 저장할 수 없습니다.",
|
||||
"saveFailedHelp": "호스트가 확인되었지만 Orca가 호스트를 저장할 수 없습니다. 이름과 로컬 설정 저장소를 확인한 후 다시 시도하세요."
|
||||
"saveFailedHelp": "호스트가 확인되었지만 Orca가 호스트를 저장할 수 없습니다. 이름과 로컬 설정 저장소를 확인한 후 다시 시도하세요.",
|
||||
"differentServer": "This link is for a different server",
|
||||
"differentServerHelp": "This pairing link is for a different server. Create the link on the server you are re-pairing."
|
||||
},
|
||||
"CloudVmSetupGuide": {
|
||||
"title": "클라우드 VM 만들기",
|
||||
@@ -12649,6 +12656,13 @@
|
||||
"remove": "제거",
|
||||
"turnOn": "실험적에서 켜기",
|
||||
"forceUpdate": "강제 업데이트"
|
||||
},
|
||||
"RuntimeRepairDialog": {
|
||||
"title": "Re-pair {{name}}",
|
||||
"description": "{{name}} no longer accepts this computer. On {{name}}, open Settings → Remote Orca Servers → Share this host, create a link for another Orca client, then paste it here.",
|
||||
"submit": "Re-pair",
|
||||
"success": "Re-paired with {{name}}",
|
||||
"failed": "Re-pair failed"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
|
||||
@@ -387,7 +387,8 @@
|
||||
"loopbackPairingBlocked": "此访问链接会指回此设备。",
|
||||
"remotePairingUnreachable": "无法连接 {{endpoint}} 上的 Orca。",
|
||||
"remotePairingInvalidDetails": "此访问链接包含无效的连接信息。",
|
||||
"remotePairingSaveFailed": "Orca 验证了主机但无法保存它。检查浏览器存储并重试。"
|
||||
"remotePairingSaveFailed": "Orca 验证了主机但无法保存它。检查浏览器存储并重试。",
|
||||
"remotePairingRepairUnavailable": "Re-pairing is not available in the browser. Pair this browser with the server again."
|
||||
},
|
||||
"web": {
|
||||
"preload": {
|
||||
@@ -5852,7 +5853,8 @@
|
||||
"sidebarRowMissing": "目标不再存在",
|
||||
"failedUnnestWorkspace": "展开工作区失败",
|
||||
"groupRenameFailed": "重命名组失败",
|
||||
"groupRenameFailedDesc": "Orca 无法与该群组的主持人确认新名称。重新连接后重新检查该组。"
|
||||
"groupRenameFailedDesc": "Orca 无法与该群组的主持人确认新名称。重新连接后重新检查该组。",
|
||||
"hostRepairRequired": "Re-pair required"
|
||||
},
|
||||
"WorktreeMetaDialog": {
|
||||
"3db0a2a593": "取消",
|
||||
@@ -6295,7 +6297,8 @@
|
||||
"59b553e2aa": "断开连接",
|
||||
"2d3e4f5a6b": "检查连接",
|
||||
"3c4d5e6f7a": "管理主机…",
|
||||
"6e7f8a9b0c": "删除主机..."
|
||||
"6e7f8a9b0c": "删除主机...",
|
||||
"repair": "Re-pair…"
|
||||
},
|
||||
"WorkspaceKanbanDrawer": {
|
||||
"1975a4e480": "任务状态同步失败",
|
||||
@@ -8373,7 +8376,9 @@
|
||||
"serverWorkspaceWindowClosed": "工作区窗口已关闭",
|
||||
"serverRuntimeUnavailable": "Orca 不可用",
|
||||
"serverRuntimeUnavailableDescription": "SSH 传输已连接,但 Orca 运行时未应答。主机可能仍在运行。",
|
||||
"serverReconnecting": "重新连接"
|
||||
"serverReconnecting": "重新连接",
|
||||
"accessRevoked": "{{value0}} no longer accepts this computer. Re-pair it with a new access link.",
|
||||
"repair": "Re-pair…"
|
||||
},
|
||||
"RuntimePairingGeneratedUrlRows": {
|
||||
"0495f68959": "复制 {{value0}}"
|
||||
@@ -12079,7 +12084,9 @@
|
||||
"networkConnection": "网络连接",
|
||||
"notAttempted": "未尝试",
|
||||
"saveFailed": "无法保存主机",
|
||||
"saveFailedHelp": "主机已验证,但 Orca 无法保存。检查名称和本地设置存储,然后重试。"
|
||||
"saveFailedHelp": "主机已验证,但 Orca 无法保存。检查名称和本地设置存储,然后重试。",
|
||||
"differentServer": "This link is for a different server",
|
||||
"differentServerHelp": "This pairing link is for a different server. Create the link on the server you are re-pairing."
|
||||
},
|
||||
"CloudVmSetupGuide": {
|
||||
"title": "创建云虚拟机",
|
||||
@@ -12649,6 +12656,13 @@
|
||||
"remove": "移除",
|
||||
"turnOn": "在实验性中开启",
|
||||
"forceUpdate": "强制更新"
|
||||
},
|
||||
"RuntimeRepairDialog": {
|
||||
"title": "Re-pair {{name}}",
|
||||
"description": "{{name}} no longer accepts this computer. On {{name}}, open Settings → Remote Orca Servers → Share this host, create a link for another Orca client, then paste it here.",
|
||||
"submit": "Re-pair",
|
||||
"success": "Re-paired with {{name}}",
|
||||
"failed": "Re-pair failed"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
|
||||
@@ -81,6 +81,11 @@ export function translateRemotePairingFailureDescription(
|
||||
'auto.components.settings.RuntimeHostAccessForm.saveFailedHelp',
|
||||
'The host was verified, but Orca could not save it. Check the name and local settings storage, then try again.'
|
||||
)
|
||||
case 'different-server':
|
||||
return translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.differentServerHelp',
|
||||
'This pairing link is for a different server. Create the link on the server you are re-pairing.'
|
||||
)
|
||||
case 'host-unreachable':
|
||||
return translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.unavailableHelp',
|
||||
|
||||
@@ -168,6 +168,15 @@ export function createRuntimeEnvironmentsApi(): NonNullable<
|
||||
runtimeStatus
|
||||
}
|
||||
},
|
||||
// Why: the browser keeps a single paired server; pairing it again replaces that one.
|
||||
repairFromPairingCode: async () => ({
|
||||
ok: false,
|
||||
kind: 'environment-save-failed',
|
||||
message: translate(
|
||||
'auto.web.webPreloadApi.remotePairingRepairUnavailable',
|
||||
'Re-pairing is not available in the browser. Pair this browser with the server again.'
|
||||
)
|
||||
}),
|
||||
resolve: async ({ selector }) =>
|
||||
redactStoredWebRuntimeEnvironment(resolveEnvironment(selector)),
|
||||
remove: async ({ selector }) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SshConnectionStatus } from './ssh-types'
|
||||
import type { RuntimeHostBlockedReason } from './runtime-host-status'
|
||||
|
||||
export type ExecutionHostHealth =
|
||||
| 'local'
|
||||
@@ -29,7 +30,10 @@ export function getSshConnectionHealth(
|
||||
}
|
||||
}
|
||||
|
||||
export function getExecutionHostHealthLabel(health: ExecutionHostHealth): string {
|
||||
export function getExecutionHostHealthLabel(
|
||||
health: ExecutionHostHealth,
|
||||
blockedReason?: RuntimeHostBlockedReason
|
||||
): string {
|
||||
switch (health) {
|
||||
case 'local':
|
||||
return 'Local'
|
||||
@@ -38,7 +42,7 @@ export function getExecutionHostHealthLabel(health: ExecutionHostHealth): string
|
||||
case 'connecting':
|
||||
return 'Connecting'
|
||||
case 'blocked':
|
||||
return 'Update needed'
|
||||
return blockedReason === 'access-revoked' ? 'Re-pair needed' : 'Update needed'
|
||||
case 'disconnected':
|
||||
return 'Disconnected'
|
||||
case 'error':
|
||||
|
||||
@@ -33,6 +33,10 @@ function diagnostics(
|
||||
|
||||
// Mirrors applyRuntimeHostStatusSnapshot: only a verified, unretired snapshot projects `status`.
|
||||
function healthFor(snapshot: RuntimeHostStatusSnapshot): string | undefined {
|
||||
return hostFor(snapshot)?.health
|
||||
}
|
||||
|
||||
function hostFor(snapshot: RuntimeHostStatusSnapshot) {
|
||||
const status =
|
||||
snapshot.verification === 'verified' && !snapshot.retired && snapshot.status
|
||||
? { ...snapshot.status, remoteControl: snapshot.remoteControl ?? undefined }
|
||||
@@ -52,7 +56,7 @@ function healthFor(snapshot: RuntimeHostStatusSnapshot): string | undefined {
|
||||
}
|
||||
]
|
||||
])
|
||||
}).find((host) => host.id === 'runtime:env-1')?.health
|
||||
}).find((host) => host.id === 'runtime:env-1')
|
||||
}
|
||||
|
||||
function snapshot(patch: Partial<RuntimeHostStatusSnapshot>): RuntimeHostStatusSnapshot {
|
||||
@@ -124,4 +128,26 @@ describe('runtime host health while shared control connects (#10704)', () => {
|
||||
expect(healthFor(latest())).toBe('connecting')
|
||||
owner.dispose()
|
||||
})
|
||||
|
||||
it('carries why a blocked host refused this client', () => {
|
||||
expect(
|
||||
hostFor(snapshot({ verification: 'blocked', blockedReason: 'access-revoked' }))
|
||||
).toMatchObject({ health: 'blocked', blockedReason: 'access-revoked' })
|
||||
expect(
|
||||
hostFor(snapshot({ verification: 'blocked', blockedReason: 'incompatible' }))
|
||||
).toMatchObject({ health: 'blocked', blockedReason: 'incompatible' })
|
||||
expect(
|
||||
hostFor(
|
||||
snapshot({
|
||||
status: { ...STATUS, runtimeProtocolVersion: 0, protocolVersion: 0 }
|
||||
})
|
||||
)
|
||||
).toMatchObject({ health: 'blocked', blockedReason: 'incompatible' })
|
||||
expect(hostFor(snapshot({}))).not.toHaveProperty('blockedReason')
|
||||
const retired = hostFor(
|
||||
snapshot({ verification: 'blocked', retired: true, transport: 'disconnected' })
|
||||
)
|
||||
expect(retired).toMatchObject({ health: 'disconnected' })
|
||||
expect(retired).not.toHaveProperty('blockedReason')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RuntimeEnvironmentStatus } from './runtime-host-status'
|
||||
import type { RuntimeEnvironmentStatus, RuntimeHostBlockedReason } from './runtime-host-status'
|
||||
import {
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
getLocalExecutionHostLabel,
|
||||
@@ -36,6 +36,8 @@ export type ExecutionHostRegistryEntry = {
|
||||
health: ExecutionHostHealth
|
||||
connectionStatus?: SshConnectionStatus
|
||||
compatibility?: RuntimeCompatVerdict
|
||||
/** Present only while health is blocked; local evidence, never published to peers. */
|
||||
blockedReason?: RuntimeHostBlockedReason
|
||||
capabilities?: readonly string[]
|
||||
appVersion?: string | null
|
||||
protocolVersion?: number | null
|
||||
@@ -160,12 +162,18 @@ function addRuntimeHost(
|
||||
snapshot?.transport === 'connecting'
|
||||
? 'connecting'
|
||||
: runtimeControlHealth(remoteControl)
|
||||
const health = controlHealth ?? runtimeHealth(status, compatibility, remoteControl)
|
||||
const blockedReason =
|
||||
health === 'blocked'
|
||||
? (snapshot?.verification === 'blocked' && snapshot.blockedReason) || 'incompatible'
|
||||
: undefined
|
||||
setHost(hosts, {
|
||||
id: hostId,
|
||||
kind: 'runtime',
|
||||
label,
|
||||
detail: 'Orca server',
|
||||
health: controlHealth ?? runtimeHealth(status, compatibility, remoteControl),
|
||||
health,
|
||||
...(blockedReason ? { blockedReason } : {}),
|
||||
compatibility: compatibility ?? undefined,
|
||||
capabilities: metadata?.capabilities,
|
||||
appVersion: runtimeStatus?.appVersion ?? metadata?.appVersion ?? null,
|
||||
|
||||
@@ -10,6 +10,8 @@ export type RemotePairingFailureKind =
|
||||
| 'protocol-incompatible'
|
||||
| 'connection-interrupted'
|
||||
| 'environment-save-failed'
|
||||
/** A re-pair link that reaches a server other than the saved one. */
|
||||
| 'different-server'
|
||||
|
||||
export type RemotePairingFailure = {
|
||||
ok: false
|
||||
|
||||
@@ -104,7 +104,7 @@ export function removeEnvironment(userDataPath: string, selector: string): Known
|
||||
export function updateEnvironmentFromPairingCode(
|
||||
userDataPath: string,
|
||||
selector: string,
|
||||
args: { pairingCode: string; now?: number }
|
||||
args: { pairingCode: string; now?: number; runtimeId?: string }
|
||||
): KnownRuntimeEnvironment {
|
||||
const offer = parseRemotePairingCode(userDataPath, args.pairingCode)
|
||||
assertNoIndependentSshAccess(
|
||||
@@ -119,7 +119,7 @@ export function updateEnvironmentFromPairingCode(
|
||||
name: existing.name,
|
||||
now: existing.createdAt,
|
||||
offer,
|
||||
runtimeId: existing.runtimeId,
|
||||
runtimeId: args.runtimeId ?? existing.runtimeId,
|
||||
...(existing.source ? { source: existing.source } : {}),
|
||||
...getPairingConnectionDependency(existing.connectionDependency, offer)
|
||||
})
|
||||
|
||||
@@ -205,3 +205,38 @@ it.each(['unknown', 'ready'] as const)(
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['unauthorized', 'access-revoked'],
|
||||
['forbidden', 'access-revoked'],
|
||||
['protocol_version_mismatch', 'incompatible'],
|
||||
['method_not_found', 'incompatible']
|
||||
] as const)('labels a %s refusal as %s until verification leaves blocked', async (code, reason) => {
|
||||
const { owner, request } = createOwner()
|
||||
request.mockResolvedValueOnce(runtimeHostStatusFailure(code, 'refused'))
|
||||
await owner.refresh()
|
||||
expect(owner.read()).toMatchObject({
|
||||
verification: 'blocked',
|
||||
blockedReason: reason
|
||||
})
|
||||
owner.connectionChanged('ready')
|
||||
expect(owner.read().blockedReason).toBe(reason)
|
||||
await owner.refresh({ reconnect: true })
|
||||
expect(owner.read().verification).toBe('verified')
|
||||
expect(owner.read()).not.toHaveProperty('blockedReason')
|
||||
})
|
||||
|
||||
it('does not label a transient failure or a retired owner with a blocked reason', async () => {
|
||||
const { owner, request } = createOwner()
|
||||
request.mockResolvedValueOnce(runtimeHostStatusFailure('runtime_unavailable', 'offline'))
|
||||
await owner.refresh()
|
||||
expect(owner.read()).not.toHaveProperty('blockedReason')
|
||||
owner.authenticationRejected()
|
||||
expect(owner.read().blockedReason).toBe('access-revoked')
|
||||
owner.dispose()
|
||||
expect(owner.read()).toMatchObject({
|
||||
verification: 'blocked',
|
||||
retired: true
|
||||
})
|
||||
expect(owner.read()).not.toHaveProperty('blockedReason')
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
isRuntimeHostStatusBlocked,
|
||||
runtimeHostStatusBlockedReason,
|
||||
runtimeHostStatusError,
|
||||
runtimeHostStatusFailure,
|
||||
type RuntimeHostStatusResponse,
|
||||
@@ -221,9 +221,11 @@ export class RuntimeHostStatusOwner {
|
||||
this.update({ status: response.result, checkedAt: Date.now(), verification: 'verified' })
|
||||
this.persistent = this.options.verified(response, this.active)
|
||||
} else {
|
||||
const blockedReason = runtimeHostStatusBlockedReason(response)
|
||||
this.update({
|
||||
checkedAt: Date.now(),
|
||||
verification: isRuntimeHostStatusBlocked(response) ? 'blocked' : 'unavailable'
|
||||
verification: blockedReason ? 'blocked' : 'unavailable',
|
||||
...(blockedReason ? { blockedReason } : {})
|
||||
})
|
||||
this.scheduleRetry()
|
||||
}
|
||||
@@ -268,7 +270,14 @@ export class RuntimeHostStatusOwner {
|
||||
}
|
||||
|
||||
private update(patch: Partial<RuntimeHostStatusSnapshot>): void {
|
||||
this.snapshot = { ...this.snapshot, ...patch, sequence: ++publicationSequence }
|
||||
const { blockedReason, ...next } = { ...this.snapshot, ...patch }
|
||||
// Why: a retired owner is a disconnect, not a refusal, so it never carries a reason.
|
||||
const keepReason = blockedReason && next.verification === 'blocked' && !next.retired
|
||||
this.snapshot = {
|
||||
...next,
|
||||
...(keepReason ? { blockedReason } : {}),
|
||||
sequence: ++publicationSequence
|
||||
}
|
||||
this.options.publish(this.snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import type { RuntimeStatus } from './runtime-types'
|
||||
|
||||
export const RUNTIME_HOST_STATUS_CHANNEL = 'runtimeEnvironments:statusChanged'
|
||||
|
||||
/** Why a host refused this client: its pairing was revoked, or the versions cannot talk. */
|
||||
export type RuntimeHostBlockedReason = 'access-revoked' | 'incompatible'
|
||||
|
||||
/** Local client state; never exchanged with the paired host. */
|
||||
export type RuntimeHostStatusSnapshot = {
|
||||
environmentId: string
|
||||
@@ -12,6 +15,8 @@ export type RuntimeHostStatusSnapshot = {
|
||||
checkedAt: number
|
||||
status: RuntimeStatus | null
|
||||
verification: 'checking' | 'verified' | 'unavailable' | 'blocked'
|
||||
/** Set only while a refused status probe holds `verification` at blocked. */
|
||||
blockedReason?: RuntimeHostBlockedReason
|
||||
transport: 'unknown' | 'connecting' | 'ready' | 'disconnected'
|
||||
remoteControl?: RemoteRuntimeSharedConnectionDiagnostics | null
|
||||
retired?: true
|
||||
@@ -98,14 +103,22 @@ export function runtimeHostStatusError(error: unknown): RuntimeRpcFailure {
|
||||
return runtimeHostStatusFailure(code, error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
|
||||
export function isRuntimeHostStatusBlocked(response: RuntimeRpcFailure): boolean {
|
||||
const ACCESS_REVOKED_CODES = ['unauthorized', 'forbidden']
|
||||
|
||||
/** Null when the failure is transient and verification should keep retrying. */
|
||||
export function runtimeHostStatusBlockedReason(
|
||||
response: RuntimeRpcFailure
|
||||
): RuntimeHostBlockedReason | null {
|
||||
if (ACCESS_REVOKED_CODES.includes(response.error.code)) {
|
||||
return 'access-revoked'
|
||||
}
|
||||
return [
|
||||
'unauthorized',
|
||||
'forbidden',
|
||||
'invalid_argument',
|
||||
'invalid_runtime_response',
|
||||
'protocol_version_mismatch',
|
||||
'method_not_found',
|
||||
'unsupported_method'
|
||||
].includes(response.error.code)
|
||||
? 'incompatible'
|
||||
: null
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '../execution-host'
|
||||
import type { ExecutionHostHealth } from '../execution-host-registry'
|
||||
import type { RuntimeCompatVerdict } from '../protocol-compat'
|
||||
import type { RuntimeHostBlockedReason } from '../runtime-host-status'
|
||||
import type { SshConnectionStatus } from '../ssh-types'
|
||||
import type { Repo } from '../repo-types'
|
||||
import type { Row } from './row-types'
|
||||
@@ -26,6 +27,8 @@ export type HostHeaderRow = {
|
||||
// Why: blocked-host guidance in the header menu needs the verdict reason so
|
||||
// it can deep-link an "Update server/client required" row per skew direction.
|
||||
compatibility?: RuntimeCompatVerdict
|
||||
// Why: a revoked pairing needs "Re-pair", not the update guidance a version block gets.
|
||||
blockedReason?: RuntimeHostBlockedReason
|
||||
connectionStatus?: SshConnectionStatus
|
||||
collapsed: boolean
|
||||
count: number
|
||||
@@ -40,6 +43,7 @@ export type HostSectionOption = {
|
||||
detail: string
|
||||
health: ExecutionHostHealth
|
||||
compatibility?: RuntimeCompatVerdict
|
||||
blockedReason?: RuntimeHostBlockedReason
|
||||
connectionStatus?: SshConnectionStatus
|
||||
}
|
||||
|
||||
@@ -295,6 +299,7 @@ export function addHostSectionRows(args: {
|
||||
detail: host.detail,
|
||||
health: host.health,
|
||||
compatibility: host.compatibility,
|
||||
...(host.blockedReason ? { blockedReason: host.blockedReason } : {}),
|
||||
connectionStatus: host.connectionStatus,
|
||||
collapsed,
|
||||
count: countWorkspaceRows(hostRows)
|
||||
|
||||
Reference in New Issue
Block a user