From b97a1a62bc4fee1da9aaa2d2d9cf506d77988200 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Thu, 15 Oct 2026 14:34:16 -0700 Subject: [PATCH] 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 * test: type the held-move fixture without an assertion --------- Co-authored-by: m4air Co-authored-by: Claude --- .../orcad/orcad-app-version-stamp.test.ts | 49 +++++ src/main/orcad/orcad-host-adapters.ts | 23 +++ src/main/ssh/orcad-runtime-conversion.test.ts | 16 ++ ...h-host-server-on-connect-held-move.test.ts | 186 ++++++++++++++++++ src/main/ssh/ssh-host-server-on-connect.ts | 11 +- src/main/ssh/ssh-host-unserved-status.ts | 9 +- src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/fr.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + .../ssh/ssh-host-server-status-copy.test.ts | 18 ++ .../src/ssh/ssh-host-server-status-copy.ts | 12 +- src/renderer/src/store/repos/repo-removal.ts | 19 +- ...ect-removal-runtime-terminal-order.test.ts | 46 +++++ src/renderer/src/store/slices/repos.test.ts | 4 + .../ssh-managed-server-status-admission.ts | 3 +- src/shared/ssh-types.ts | 2 + 19 files changed, 398 insertions(+), 6 deletions(-) create mode 100644 src/main/orcad/orcad-app-version-stamp.test.ts create mode 100644 src/main/ssh/ssh-host-server-on-connect-held-move.test.ts diff --git a/src/main/orcad/orcad-app-version-stamp.test.ts b/src/main/orcad/orcad-app-version-stamp.test.ts new file mode 100644 index 00000000000..42866e8d84c --- /dev/null +++ b/src/main/orcad/orcad-app-version-stamp.test.ts @@ -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() + }) +}) diff --git a/src/main/orcad/orcad-host-adapters.ts b/src/main/orcad/orcad-host-adapters.ts index 92b482566db..d375cb77088 100644 --- a/src/main/orcad/orcad-host-adapters.ts +++ b/src/main/orcad/orcad-host-adapters.ts @@ -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()) } diff --git a/src/main/ssh/orcad-runtime-conversion.test.ts b/src/main/ssh/orcad-runtime-conversion.test.ts index 790d907121e..dc562057aee 100644 --- a/src/main/ssh/orcad-runtime-conversion.test.ts +++ b/src/main/ssh/orcad-runtime-conversion.test.ts @@ -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. diff --git a/src/main/ssh/ssh-host-server-on-connect-held-move.test.ts b/src/main/ssh/ssh-host-server-on-connect-held-move.test.ts new file mode 100644 index 00000000000..47ddbdedabb --- /dev/null +++ b/src/main/ssh/ssh-host-server-on-connect-held-move.test.ts @@ -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[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' } + }) + }) +}) diff --git a/src/main/ssh/ssh-host-server-on-connect.ts b/src/main/ssh/ssh-host-server-on-connect.ts index 9b63b43e23f..83bb4431a1b 100644 --- a/src/main/ssh/ssh-host-server-on-connect.ts +++ b/src/main/ssh/ssh-host-server-on-connect.ts @@ -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 } diff --git a/src/main/ssh/ssh-host-unserved-status.ts b/src/main/ssh/ssh-host-unserved-status.ts index 2991854e5e0..cc0156becba 100644 --- a/src/main/ssh/ssh-host-unserved-status.ts +++ b/src/main/ssh/ssh-host-unserved-status.ts @@ -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.' diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index ba25a37a7d7..c3fbd888193 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ba4de069f0d..2e74f2ad0f9 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 625e9e880e2..f9332bf6286 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -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.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 9c5e18a06fc..38825992cbb 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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 で閉じるか、ホスト上で終了してから再接続してください。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 1575022d30b..eb59f8a2f3a 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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에서 닫거나 호스트에서 종료한 후 다시 연결하세요.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index d84bb132534..4d61dca1dff 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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 中关闭它们,或在主机上结束它们,然后重新连接。", diff --git a/src/renderer/src/ssh/ssh-host-server-status-copy.test.ts b/src/renderer/src/ssh/ssh-host-server-status-copy.test.ts index 1e8cda30f30..c049c608185 100644 --- a/src/renderer/src/ssh/ssh-host-server-status-copy.test.ts +++ b/src/renderer/src/ssh/ssh-host-server-status-copy.test.ts @@ -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 } diff --git a/src/renderer/src/ssh/ssh-host-server-status-copy.ts b/src/renderer/src/ssh/ssh-host-server-status-copy.ts index 967f6ab5818..8cc39d7e784 100644 --- a/src/renderer/src/ssh/ssh-host-server-status-copy.ts +++ b/src/renderer/src/ssh/ssh-host-server-status-copy.ts @@ -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) } } } diff --git a/src/renderer/src/store/repos/repo-removal.ts b/src/renderer/src/store/repos/repo-removal.ts index 13ad0478827..163a3af8d17 100644 --- a/src/renderer/src/store/repos/repo-removal.ts +++ b/src/renderer/src/store/repos/repo-removal.ts @@ -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 { + try { + const environments = await window.api.runtimeEnvironments.list() + return environments.some((environment) => environment.id === environmentId) + } catch { + return true + } +} + export function createRepoRemovalActions( set: Parameters>[0], get: Parameters>[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 }) diff --git a/src/renderer/src/store/slices/project-removal-runtime-terminal-order.test.ts b/src/renderer/src/store/slices/project-removal-runtime-terminal-order.test.ts index 441ac3c6dad..8191ea84fc7 100644 --- a/src/renderer/src/store/slices/project-removal-runtime-terminal-order.test.ts +++ b/src/renderer/src/store/slices/project-removal-runtime-terminal-order.test.ts @@ -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( diff --git a/src/renderer/src/store/slices/repos.test.ts b/src/renderer/src/store/slices/repos.test.ts index d5b2ef6b1f7..31e7c7e549c 100644 --- a/src/renderer/src/store/slices/repos.test.ts +++ b/src/renderer/src/store/slices/repos.test.ts @@ -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, diff --git a/src/shared/ssh-managed-server-status-admission.ts b/src/shared/ssh-managed-server-status-admission.ts index c05d2499876..c6d612b75ca 100644 --- a/src/shared/ssh-managed-server-status-admission.ts +++ b/src/shared/ssh-managed-server-status-admission.ts @@ -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 } : {}) } } } diff --git a/src/shared/ssh-types.ts b/src/shared/ssh-types.ts index e18a0654978..b4774b4bf58 100644 --- a/src/shared/ssh-types.ts +++ b/src/shared/ssh-types.ts @@ -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. */