fix(ssh): a paused move to the managed server says so; servers show their real version; a gone server's project can be removed (#27267) (#29208)

* fix(ssh): a paused move to the managed server says so, servers report their real version, and a gone server's project can be removed (#27267)

Co-Authored-By: Claude <noreply@anthropic.com>

* test: type the held-move fixture without an assertion

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
OrcaWin
2026-10-15 14:34:16 -07:00
committed by GitHub
co-authored by Claude m4air
parent 88bd5db42e
commit b97a1a62bc
19 changed files with 398 additions and 6 deletions
@@ -0,0 +1,49 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { stampOrcadAppVersion } from './orcad-host-adapters'
describe('the Orca version a server reports', () => {
let root: string
let saved: string | undefined
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'orcad-app-version-'))
saved = process.env.ORCA_APP_VERSION
delete process.env.ORCA_APP_VERSION
})
afterEach(() => {
rmSync(root, { recursive: true, force: true })
if (saved === undefined) {
delete process.env.ORCA_APP_VERSION
} else {
process.env.ORCA_APP_VERSION = saved
}
})
it('is the release its bundle was built from, not a dev placeholder', () => {
mkdirSync(join(root, 'out', 'cli'), { recursive: true })
writeFileSync(join(root, 'out', 'package.json'), JSON.stringify({ version: '1.4.224' }))
stampOrcadAppVersion(() => root)
expect(process.env.ORCA_APP_VERSION).toBe('1.4.224')
})
it('replaces a version inherited from the Orca that started it', () => {
process.env.ORCA_APP_VERSION = '1.4.300'
mkdirSync(join(root, 'out'), { recursive: true })
writeFileSync(join(root, 'out', 'package.json'), JSON.stringify({ version: '1.4.224' }))
stampOrcadAppVersion(() => root)
expect(process.env.ORCA_APP_VERSION).toBe('1.4.224')
})
it('leaves the environment alone when the bundle has no version', () => {
stampOrcadAppVersion(() => root)
expect(process.env.ORCA_APP_VERSION).toBeUndefined()
stampOrcadAppVersion(() => {
throw new Error('orcad_install_root_unavailable')
})
expect(process.env.ORCA_APP_VERSION).toBeUndefined()
})
})
+23
View File
@@ -1,5 +1,8 @@
/** The Node host's stand-ins for Electron's app environment and secret store. */
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import process from 'node:process'
import { ORCAD_CLI_PACKAGE_FILENAME } from '../../shared/orcad-artifacts'
import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environment'
import { setSecretStore, type SecretStore } from '../../shared/secret-store'
import { resolveOrcadInstallRoot, resolveOrcadPath } from './orcad-app-paths'
@@ -66,7 +69,27 @@ function createNodeSecretStore(): SecretStore {
}
}
/**
* The Orca release this server was built from, stamped into the bundle's own `out/package.json`.
* Why: `status.get` and the updater report ORCA_APP_VERSION, which only the desktop's preflight
* sets, so a server read `0.0.0-dev`. The bundle wins over an inherited value from another build.
*/
export function stampOrcadAppVersion(installRoot: () => string = resolveOrcadInstallRoot): void {
let version: unknown
try {
const path = join(installRoot(), ...ORCAD_CLI_PACKAGE_FILENAME.split('/'))
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
version = parsed && typeof parsed === 'object' && 'version' in parsed ? parsed.version : null
} catch {
return
}
if (typeof version === 'string' && version.length > 0) {
process.env.ORCA_APP_VERSION = version
}
}
export function installOrcadHostAdapters(): void {
stampOrcadAppVersion()
setAppEnvironment(createNodeAppEnvironment())
setSecretStore(createNodeSecretStore())
}
@@ -287,6 +287,22 @@ describe('backing out a conversion whose server is registered but never committe
expect(listOrcadMigrationSourceCutovers(userDataPath)).toHaveLength(1)
})
it('finishes the same move on a later attempt once the server answers again', async () => {
destination.commit.mockRejectedValueOnce(new Error('socket closed'))
await expect(convert()).rejects.toThrow()
destination.readState.mockRejectedValueOnce(new Error('socket closed'))
await expect(abandon()).rejects.toThrow('socket closed')
const [held] = listOrcadMigrationSourceCutovers(userDataPath)
expect(store.getSshTarget(TARGET.id)?.orcadFence).toBeDefined()
await expect(convert()).resolves.toMatchObject({
outcome: 'converted',
migrationId: held!.migrationId
})
expect(destination.commits).toBe(1)
expect(listEnvironments(userDataPath)).toHaveLength(1)
})
it('clears a stale journal a stopped server left, so the host converts again', async () => {
await expect(convert()).resolves.toMatchObject({ outcome: 'converted' })
// A stopped server from a build that left its journal behind.
@@ -0,0 +1,186 @@
/**
* #27267: a move to the managed server that stops short must never leave the host locked with no
* way forward. Each stop either releases the fence or keeps it, says so, and resumes next connect.
*/
import { describe, expect, it, vi } from 'vitest'
import type { SshTarget } from '../../shared/ssh-types'
import {
resolveHostServerOnConnect,
type HostServerOnConnectDeps
} from './ssh-host-server-on-connect'
import { hostServerDepsStub } from './ssh-host-server-on-connect-test-deps'
import { unservedHostServerMessage, unservedServerStatus } from './ssh-host-unserved-status'
import { admitSshManagedServerStatus } from '../../shared/ssh-managed-server-status-admission'
const target: SshTarget = { id: 'ssh-1', label: 'Box', host: 'box', port: 22, username: 'me' }
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the orchestrator reads only the environment id.
const environment = { id: 'env-1' } as never
/** The journal and registration a conversion leaves, as the connect's collaborators see them. */
function fencedHost(phase: 'source-fenced' | 'destination-staged', registered: boolean) {
const host: { phase: string | null; registered: boolean } = { phase, registered }
return {
host,
deps: (overrides: Parameters<typeof hostServerDepsStub>[0] = {}) =>
hostServerDepsStub({
managedEnvironmentId: () => (host.phase && host.registered ? 'env-1' : null),
hasUnfinishedConversion: () =>
host.phase === 'source-fenced' || host.phase === 'destination-staged',
isFencedBeforeStaging: () => host.phase === 'source-fenced',
...overrides
})
}
}
describe('a move to the managed server that stops short', () => {
it('keeps the host locked and says the move is paused when the server cannot answer', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const { deps } = fencedHost('source-fenced', true)
const d = deps({
convert: vi.fn(async () => {
throw new Error('Could not connect to the remote Orca runtime.')
}),
abandonConversion: vi.fn(async () => {
throw new Error('Could not connect to the remote Orca runtime.')
})
})
const result = await resolveHostServerOnConnect(target, d)
expect(result).toMatchObject({ route: 'unserved', reason: 'failed', held: true })
expect(d.abandonConversion).toHaveBeenCalledWith(target)
// Never an unavailability record: that would stop later connects from resuming.
expect(d.recordUnavailable).not.toHaveBeenCalled()
if (result.route !== 'unserved') {
throw new Error('expected unserved')
}
expect(unservedHostServerMessage(result)).toContain('Connect again to resume')
expect(unservedServerStatus(result)).toMatchObject({ kind: 'relay', held: true })
warn.mockRestore()
})
it('resumes the same move on the next connect once the server answers', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const { host, deps } = fencedHost('destination-staged', true)
const failing = deps({
convert: vi.fn(async () => {
throw new Error('socket closed')
}),
abandonConversion: vi.fn(async () => {
throw new Error('socket closed')
})
})
await expect(resolveHostServerOnConnect(target, failing)).resolves.toMatchObject({
held: true
})
const convert = vi.fn(async () => {
host.phase = 'destination-committed'
return { outcome: 'converted' as const, environment, migrationId: 'm' }
})
const next = deps({ convert })
await expect(resolveHostServerOnConnect(target, next)).resolves.toEqual({
route: 'managed',
environmentId: 'env-1'
})
// Resumed through the registered server, without a fresh census or a second fence.
expect(next.ensureTunnel).toHaveBeenCalledWith('env-1')
expect(convert).toHaveBeenCalledWith(target, null)
expect(next.relayTerminals).not.toHaveBeenCalled()
warn.mockRestore()
})
it('gives the host back, with no lock to report, when the server proves it holds nothing', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const { host, deps } = fencedHost('destination-staged', true)
const d = deps({
convert: vi.fn(async () => {
throw new Error('orcad_migration_commit_not_committed:staged')
}),
abandonConversion: vi.fn(async () => {
host.phase = null
})
})
const result = await resolveHostServerOnConnect(target, d)
expect(result).toEqual({
route: 'unserved',
reason: 'failed',
detail: 'orcad_migration_commit_not_committed:staged'
})
if (result.route === 'unserved') {
expect(unservedHostServerMessage(result)).toBe(
'Orca couldn’t start its server on this host. Try connecting again.'
)
}
warn.mockRestore()
})
it('releases a fence whose server never registered when the deploy fails or defers', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const stops: HostServerOnConnectDeps['convert'][] = [
async () => {
throw new Error('upload failed')
},
async () => ({
outcome: 'deferred' as const,
candidateVersion: '0.1.0+abc',
code: 'orcad_update_fence_held',
reason: 'Another desktop is updating this host.'
})
]
for (const convert of stops) {
const { host, deps } = fencedHost('source-fenced', false)
const d = deps({
convert,
abandonConversion: vi.fn(async () => {
host.phase = null
})
})
const result = await resolveHostServerOnConnect(target, d)
expect(result).toMatchObject({ route: 'unserved' })
expect(result).not.toHaveProperty('held')
expect(d.abandonConversion).toHaveBeenCalledWith(target)
expect(host.phase).toBeNull()
}
warn.mockRestore()
})
it('finishes, rather than reports a lock, when the abort finds the move already committed', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const { host, deps } = fencedHost('destination-staged', true)
const lost = deps({
convert: vi.fn(async () => {
throw new Error('orcad_migration_commit_reply_lost')
}),
// The abort's re-read found the commit landed and recorded it.
abandonConversion: vi.fn(async () => {
host.phase = 'destination-committed'
})
})
await expect(resolveHostServerOnConnect(target, lost)).resolves.not.toHaveProperty('held')
const next = deps()
await expect(resolveHostServerOnConnect(target, next)).resolves.toEqual({
route: 'managed',
environmentId: 'env-1'
})
expect(next.convert).not.toHaveBeenCalled()
expect(next.retainCommittedSource).toHaveBeenCalledWith(target)
warn.mockRestore()
})
it('keeps a refusal’s own guidance even while the host stays locked', () => {
const decision = {
route: 'unserved' as const,
reason: 'refused' as const,
detail: 'remove that project from this desktop and connect again',
held: true as const
}
expect(unservedHostServerMessage(decision)).toBe(decision.detail)
})
it('carries the lock to the renderer, and drops anything but a literal true', () => {
expect(admitSshManagedServerStatus({ kind: 'relay', reason: 'failed', held: true })).toEqual({
managedServer: { kind: 'relay', reason: 'failed', held: true }
})
expect(admitSshManagedServerStatus({ kind: 'relay', reason: 'failed', held: 'yes' })).toEqual({
managedServer: { kind: 'relay', reason: 'failed' }
})
})
})
+10 -1
View File
@@ -61,6 +61,8 @@ export type HostServerOnConnectResult =
detail?: string
terminals?: number
terminalsElsewhere?: boolean
/** A move that stopped short kept the host locked; the next connect resumes it. */
held?: true
}
export type HostServerTerminalVerdict = {
@@ -326,9 +328,16 @@ async function unfinished(
// A staged or registered destination keeps its fence; the connect reports it below.
console.warn('[ssh] The managed Orca server setup keeps its fence:', error)
}
// Why re-read: only the journal says whether the abort released the host or left it locked.
const held = !empty && !unavailable && deps.hasUnfinishedConversion(target)
if (!unavailable) {
// Shown under the SSH host, with the host's orcad.log tail when the failure carries one.
return { route: 'unserved', reason, detail: boundedDetail(failure) }
return {
route: 'unserved',
reason,
detail: boundedDetail(failure),
...(held ? { held: true as const } : {})
}
}
deps.recordUnavailable(target, unavailable)
return { route: 'unserved', reason: 'orcad_unavailable', detail: unavailable }
+8 -1
View File
@@ -13,12 +13,19 @@ export function unservedServerStatus(
reason: decision.reason,
...(decision.detail ? { detail: decision.detail } : {}),
...(decision.terminals !== undefined ? { terminals: decision.terminals } : {}),
...(decision.terminalsElsewhere ? { terminalsElsewhere: true } : {})
...(decision.terminalsElsewhere ? { terminalsElsewhere: true } : {}),
...(decision.held ? { held: true } : {})
}
}
const HELD_MOVE_MESSAGE =
'Orca paused moving this host’s projects to its managed server because it couldn’t confirm the server’s copy. Nothing is lost: the host keeps its projects and stays locked until the move finishes. Connect again to resume. If the server can’t come back, stop or forget it in Settings > Managed servers.'
/** Untranslated twin of the renderer's status line, for logs and callers without the catalog. */
export function unservedHostServerMessage(decision: UnservedDecision): string {
if (decision.held && (decision.reason === 'failed' || decision.reason === 'deferred')) {
return HELD_MOVE_MESSAGE
}
switch (decision.reason) {
case 'orcad_unavailable':
return 'This SSH host isn’t supported by this version of Orca. To keep using it, install an older version of Orca.'
+1
View File
@@ -12716,6 +12716,7 @@
"unsupportedUnknown": "This host isn’t supported by this version of Orca. To keep using it, install an older version of Orca.",
"tunnelUnsupported": "This host isn’t supported by this version of Orca: its SSH server doesn’t allow port forwarding, and Orca couldn’t reach its server through the SSH session either. To keep using it, install an older version of Orca.",
"setupRetry": "Orca couldn’t start its server on this host. Try connecting again.",
"moveHeld": "Orca paused moving this host’s projects to its managed server because it couldn’t confirm the server’s copy. Nothing is lost: the host keeps its projects and stays locked until the move finishes. Connect again to resume. If the server can’t come back, stop or forget it in Settings > Managed servers.",
"olderTerminalsUncounted": "Terminals started by an older version of Orca are still running on this host, and they don’t stop on their own. Close them in that version of Orca or end them on the host, then reconnect.",
"olderTerminalsUnverifiable": "Orca couldn’t confirm that terminals started by an older version of Orca have exited. Reconnect to try again.",
"olderTerminals_one": "{{count}} terminal started by an older version of Orca is still running on this host, and it doesn’t stop on its own. Close it in that version of Orca or end it on the host, then reconnect.",
+1
View File
@@ -12533,6 +12533,7 @@
"unsupportedUnknown": "Esta versión de Orca no admite este host. Para seguir usándolo, instala una versión anterior de Orca.",
"tunnelUnsupported": "Esta versión de Orca no admite este host: su servidor SSH no permite el reenvío de puertos y Orca tampoco pudo llegar a su servidor a través de la sesión SSH. Para seguir usándolo, instala una versión anterior de Orca.",
"setupRetry": "Orca no pudo iniciar su servidor en este host. Intenta conectarte de nuevo.",
"moveHeld": "Orca pausó el traslado de los proyectos de este host a su servidor gestionado porque no pudo confirmar la copia del servidor. No se pierde nada: el host conserva sus proyectos y queda bloqueado hasta que termine el traslado. Conéctate de nuevo para reanudarlo. Si el servidor no puede volver, detenlo u olvídalo en Configuración > Servidores gestionados.",
"olderTerminalsUncounted": "Los terminales iniciados por una versión anterior de Orca siguen ejecutándose en este host y no se detienen solos. Ciérralos en esa versión de Orca o termínalos en el host y vuelve a conectarte.",
"olderTerminalsUnverifiable": "Orca no pudo confirmar que los terminales iniciados por una versión anterior de Orca hayan terminado. Vuelve a conectarte para intentarlo de nuevo.",
"olderTerminals_one": "{{count}} terminal iniciado por una versión anterior de Orca sigue ejecutándose en este host y no se detiene solo. Ciérralo en esa versión de Orca o termínalo en el host y vuelve a conectarte.",
+1
View File
@@ -12641,6 +12641,7 @@
"unsupportedUnknown": "Cette version d’Orca ne prend pas en charge cet hôte. Pour continuer à l’utiliser, installez une version antérieure d’Orca.",
"tunnelUnsupported": "Cette version d’Orca ne prend pas en charge cet hôte : son serveur SSH n’autorise pas le transfert de port, et Orca n’a pas non plus pu joindre son serveur via la session SSH. Pour continuer à l’utiliser, installez une version antérieure d’Orca.",
"setupRetry": "Orca n’a pas pu démarrer son serveur sur cet hôte. Réessayez de vous connecter.",
"moveHeld": "Orca a mis en pause le déplacement des projets de cet hôte vers son serveur géré, car il n’a pas pu confirmer la copie du serveur. Rien n’est perdu : l’hôte conserve ses projets et reste verrouillé jusqu’à la fin du déplacement. Reconnectez-vous pour reprendre. Si le serveur ne peut pas revenir, arrêtez-le ou oubliez-le dans Réglages > Serveurs gérés.",
"olderTerminalsUncounted": "Des terminaux lancés par une version antérieure d’Orca sont toujours en cours d’exécution sur cet hôte et ne s’arrêtent pas d’eux-mêmes. Fermez-les dans cette version d’Orca ou arrêtez-les sur l’hôte, puis reconnectez-vous.",
"olderTerminalsUnverifiable": "Orca n’a pas pu confirmer que les terminaux lancés par une version antérieure d’Orca sont terminés. Reconnectez-vous pour réessayer.",
"olderTerminals_one": "{{count}} terminal lancé par une version antérieure d’Orca est toujours en cours d’exécution sur cet hôte et ne s’arrête pas de lui-même. Fermez-le dans cette version d’Orca ou arrêtez-le sur l’hôte, puis reconnectez-vous.",
+1
View File
@@ -12533,6 +12533,7 @@
"unsupportedUnknown": "このバージョンの Orca はこのホストをサポートしていません。引き続き使用するには、以前のバージョンの Orca をインストールしてください。",
"tunnelUnsupported": "このバージョンの Orca はこのホストをサポートしていません。ホストの SSH サーバーがポート転送を許可しておらず、SSH セッション経由でも Orca のサーバーに接続できませんでした。引き続き使用するには、以前のバージョンの Orca をインストールしてください。",
"setupRetry": "Orca はこのホストでサーバーを起動できませんでした。もう一度接続してください。",
"moveHeld": "サーバー側のコピーを確認できなかったため、Orca はこのホストのプロジェクトを管理対象サーバーへ移動する処理を一時停止しました。データは失われていません。移動が完了するまで、ホストはプロジェクトを保持したままロックされます。再接続すると再開します。サーバーが復帰できない場合は、設定 > 管理対象サーバーで停止するか登録を解除してください。",
"olderTerminalsUncounted": "以前のバージョンの Orca で開始されたターミナルがこのホストでまだ実行中で、自動では終了しません。そのバージョンの Orca で閉じるか、ホスト上で終了してから再接続してください。",
"olderTerminalsUnverifiable": "以前のバージョンの Orca で開始されたターミナルが終了したことを確認できませんでした。再接続してもう一度お試しください。",
"olderTerminals_one": "以前のバージョンの Orca で開始された {{count}} 個のターミナルがこのホストでまだ実行中で、自動では終了しません。そのバージョンの Orca で閉じるか、ホスト上で終了してから再接続してください。",
+1
View File
@@ -12533,6 +12533,7 @@
"unsupportedUnknown": "이 버전의 Orca는 이 호스트를 지원하지 않습니다. 계속 사용하려면 이전 버전의 Orca를 설치하세요.",
"tunnelUnsupported": "이 버전의 Orca는 이 호스트를 지원하지 않습니다. 호스트의 SSH 서버가 포트 포워딩을 허용하지 않으며, SSH 세션을 통해서도 Orca 서버에 연결할 수 없었습니다. 계속 사용하려면 이전 버전의 Orca를 설치하세요.",
"setupRetry": "Orca가 이 호스트에서 서버를 시작하지 못했습니다. 다시 연결해 보세요.",
"moveHeld": "서버의 사본을 확인하지 못해 Orca가 이 호스트의 프로젝트를 관리형 서버로 옮기는 작업을 일시 중지했습니다. 손실된 것은 없습니다. 이동이 끝날 때까지 호스트는 프로젝트를 유지한 채 잠겨 있습니다. 다시 연결하면 재개됩니다. 서버를 되살릴 수 없다면 설정 > 관리형 서버에서 중지하거나 잊기를 선택하세요.",
"olderTerminalsUncounted": "이전 버전의 Orca에서 시작한 터미널이 이 호스트에서 아직 실행 중이며 저절로 종료되지 않습니다. 해당 버전의 Orca에서 닫거나 호스트에서 종료한 후 다시 연결하세요.",
"olderTerminalsUnverifiable": "이전 버전의 Orca에서 시작한 터미널이 종료되었는지 확인할 수 없습니다. 다시 연결하여 다시 시도하세요.",
"olderTerminals_one": "이전 버전의 Orca에서 시작한 터미널 {{count}}개가 이 호스트에서 아직 실행 중이며 저절로 종료되지 않습니다. 해당 버전의 Orca에서 닫거나 호스트에서 종료한 후 다시 연결하세요.",
+1
View File
@@ -12547,6 +12547,7 @@
"unsupportedUnknown": "此版本的 Orca 不支持此主机。如需继续使用,请安装旧版本的 Orca。",
"tunnelUnsupported": "此版本的 Orca 不支持此主机:其 SSH 服务器不允许端口转发,Orca 也无法通过 SSH 会话连接到其服务器。如需继续使用,请安装旧版本的 Orca。",
"setupRetry": "Orca 无法在此主机上启动其服务器。请重新连接。",
"moveHeld": "由于无法确认服务器上的副本,Orca 已暂停将此主机的项目迁移到其受管服务器。没有任何数据丢失:在迁移完成之前,主机会保留其项目并保持锁定。重新连接即可继续。如果服务器无法恢复,请在“设置 > 受管服务器”中停止或忘记它。",
"olderTerminalsUncounted": "由旧版本 Orca 启动的终端仍在此主机上运行,且不会自行停止。请在该版本的 Orca 中关闭它们,或在主机上结束它们,然后重新连接。",
"olderTerminalsUnverifiable": "Orca 无法确认由旧版本 Orca 启动的终端是否已退出。请重新连接后重试。",
"olderTerminals_one": "由旧版本 Orca 启动的 {{count}} 个终端仍在此主机上运行,且不会自行停止。请在该版本的 Orca 中关闭它们,或在主机上结束它们,然后重新连接。",
@@ -173,6 +173,24 @@ describe('SSH host server status line', () => {
})
})
it('says a move that stopped short is paused and kept, not a plain try-again', () => {
const detail = 'Could not connect to the remote Orca runtime.'
const state = {
managedServer: {
kind: 'relay' as const,
reason: 'failed' as const,
detail,
held: true as const
}
}
expect(sshHostServerStatusLine(plain, state)).toMatchObject({
tone: 'warning',
text: expect.stringContaining('Nothing is lost'),
detail
})
expect(sshConnectFailureText(state, 'raw')).toContain('Settings > Managed servers')
})
it('puts an unserved host’s translated reason in the connect toast', () => {
const state = {
managedServer: { kind: 'relay' as const, reason: 'orcad_unavailable' as const }
@@ -172,6 +172,16 @@ function retryLine(): SshHostServerStatusLine {
}
}
function heldMoveLine(): SshHostServerStatusLine {
return {
tone: 'warning',
text: translate(
'auto.components.settings.sshHostServer.moveHeld',
'Orca paused moving this host’s projects to its managed server because it couldn’t confirm the server’s copy. Nothing is lost: the host keeps its projects and stays locked until the move finishes. Connect again to resume. If the server can’t come back, stop or forget it in Settings > Managed servers.'
)
}
}
/** Projects the server already had: this desktop's own tabs and automations for them stayed behind. */
function linkedProjectsLine(
linked: readonly SshManagedServerLinkedProject[]
@@ -299,7 +309,7 @@ function unservedLine(
}
case 'deferred':
case 'failed':
return { ...retryLine(), ...withDetail(status.detail) }
return { ...(status.held ? heldMoveLine() : retryLine()), ...withDetail(status.detail) }
}
}
+17 -2
View File
@@ -74,6 +74,16 @@ export function countOpenRepoTerminalTabs(
return count
}
/** A listing that fails reads as registered, so the host is still asked as before. */
async function isRuntimeEnvironmentRegistered(environmentId: string): Promise<boolean> {
try {
const environments = await window.api.runtimeEnvironments.list()
return environments.some((environment) => environment.id === environmentId)
} catch {
return true
}
}
export function createRepoRemovalActions(
set: Parameters<StateCreator<AppState>>[0],
get: Parameters<StateCreator<AppState>>[1]
@@ -110,7 +120,12 @@ export function createRepoRemovalActions(
executionHostId: ownerHostId
}))
)
if (target.kind === 'environment' && !vmServerDestroyed) {
// Why: a stopped or forgotten server leaves rows nothing can reach; only this desktop's copy goes.
const hostGone =
target.kind === 'environment' &&
!vmServerDestroyed &&
!(await isRuntimeEnvironmentRegistered(target.environmentId))
if (target.kind === 'environment' && !vmServerDestroyed && !hostGone) {
await stopRuntimeProjectTerminalsBeforeRemoval(
target.environmentId,
projectId,
@@ -119,7 +134,7 @@ export function createRepoRemovalActions(
}
try {
// Why: destroying the VM took its server, and the catalog holding this row, with it.
if (!vmServerDestroyed) {
if (!vmServerDestroyed && !hostGone) {
// Why: always host-scoped; this catalog may be stale and miss a same-id row on another host (#13071).
await (target.kind === 'local'
? window.api.repos.removeForHost({ repoId: projectId, hostId: ownerHostId })
@@ -131,6 +131,52 @@ describe('project removal on a runtime host', () => {
})
})
describe('project removal after its server is gone', () => {
it('drops this desktop’s copy without asking a server that was stopped or forgotten', async () => {
const call = vi.fn(() => {
throw new Error('RuntimeEnvironmentStoreError: Unknown environment: env-1')
})
vi.stubGlobal('window', {
api: {
repos: { remove: vi.fn(), removeForHost },
pty: { kill: vi.fn() },
runtimeEnvironments: { call, list: vi.fn(async () => [{ id: 'env-2' }]) },
ui: { set: vi.fn().mockResolvedValue(undefined) }
}
})
const store = seedRemoteProject()
await store.getState().removeProject('app', { hostId: 'runtime:env-1', errorFeedback: 'toast' })
expect(call).not.toHaveBeenCalled()
expect(toast.error).not.toHaveBeenCalled()
expect(store.getState().repos).toEqual([])
expect(store.getState().worktreesByRepo.app).toBeUndefined()
})
it('still asks the host when the server list cannot be read', async () => {
vi.stubGlobal('window', {
api: {
repos: { remove: vi.fn(), removeForHost },
pty: { kill: vi.fn() },
runtimeEnvironments: {
call: vi.fn(answerLikeRuntimeHost),
list: vi.fn(async () => {
throw new Error('profile store unreadable')
})
},
ui: { set: vi.fn().mockResolvedValue(undefined) }
}
})
const store = seedRemoteProject()
await store.getState().removeProject('app', { hostId: 'runtime:env-1' })
expect(hostCalls).toContain('repo.rm')
expect(store.getState().repos).toEqual([])
})
})
describe('project removal error toast', () => {
it('shows the host’s message without the IPC wrapper', async () => {
removeForHost.mockRejectedValue(
@@ -21,6 +21,7 @@ import {
reposReorder,
reposUpdate,
runtimeEnvironmentCall,
runtimeEnvironmentsList,
sshRepo
} from './repos-runtime-routing-fixture'
@@ -615,6 +616,7 @@ describe('repo slice runtime routing', () => {
})
it('removes repos through their owning runtime environment', async () => {
runtimeEnvironmentsList.mockResolvedValue([{ id: 'env-1' }])
runtimeEnvironmentCall.mockResolvedValue({
id: 'rpc-3',
ok: true,
@@ -682,6 +684,7 @@ describe('repo slice runtime routing', () => {
})
it('drops persisted visit timestamps for removed unhydrated runtime repos', async () => {
runtimeEnvironmentsList.mockResolvedValue([{ id: 'env-1' }])
runtimeEnvironmentCall.mockResolvedValue({
id: 'rpc-remove-runtime-unhydrated',
ok: true,
@@ -741,6 +744,7 @@ describe('repo slice runtime routing', () => {
})
it('stops remote runtime terminals instead of killing remote ids through local pty IPC', async () => {
runtimeEnvironmentsList.mockResolvedValue([{ id: 'env-1' }])
runtimeEnvironmentCall.mockResolvedValue({
id: 'rpc-remote',
ok: true,
@@ -78,7 +78,8 @@ export function admitSshManagedServerStatus(value: unknown): {
...(isNonNegativeSafeInteger(terminals) ? { terminals } : {}),
...('terminalsElsewhere' in value && value.terminalsElsewhere === true
? { terminalsElsewhere: true }
: {})
: {}),
...('held' in value && value.held === true ? { held: true } : {})
}
}
}
+2
View File
@@ -322,6 +322,8 @@ export type SshManagedServerStatus =
terminals?: number
/** Those terminals belong to another Orca desktop or session, so this one offers no move. */
terminalsElsewhere?: boolean
/** A move to the managed server stopped short and keeps the host locked until it resumes. */
held?: true
}
/** Plain SSH terminals and SFTP browsing only; `reason` is the ladder's classified cause. */