From 8c580a91748dc189404b3b02edd484fa56108684 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Mon, 31 Aug 2026 01:03:36 +0800 Subject: [PATCH] fix: preserve remote environment checks across settings pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Hosts settings page discarded the last successful Agent and Runtime version check whenever users switched categories or restarted GoodBuddy, forcing them to refresh the same Host again. Validated results now persist locally and remain visible until users refresh, edit or delete the Host, or start a remote environment update. Release note: 远程主机的 Agent 和 Runtime 版本检查结果现在会在设置页面切换和应用重启后保留,无需重复点击刷新版本。 --- ...istant-workbar-and-execution-spaces-prd.md | 4 +- .../src/SshHostsSettingsSection.test.tsx | 17 +++ src/renderer/src/SshHostsSettingsSection.tsx | 20 ++- .../ssh-host-remote-environment-cache.test.ts | 73 +++++++++++ .../src/ssh-host-remote-environment-cache.ts | 119 ++++++++++++++++++ src/renderer/src/test-setup.ts | 2 + 6 files changed, 233 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/ssh-host-remote-environment-cache.test.ts create mode 100644 src/renderer/src/ssh-host-remote-environment-cache.ts diff --git a/docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md b/docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md index 3e1fe164..d22a10dd 100644 --- a/docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md +++ b/docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md @@ -515,7 +515,9 @@ SSH/远程项目操作;已有 Host、项目和凭据保持不变。若当前 [SSH Host 远程环境准备与直连下载设计](../../architecture/remote-host-environment-provisioning-design.md)。 进入或切回“主机与远程执行”页面只读取本地 Host 列表,不逐台建立 SSH 连接。版本和瞬时 -下载能力仅在用户点击某台 Host 的“刷新版本”时探测。该动作不是项目连接前置条件;用户 +下载能力仅在用户点击某台 Host 的“刷新版本”时探测,成功结果在当前 Renderer 进程内按 +Host 持久化缓存,切换页面或重启应用后返回直接显示;Host 编辑、删除或开始更新远程环境后 +不复用旧结果。该动作不是项目连接前置条件;用户 切换已有远程项目只更新 Renderer 的本地项目选择,不建立 Host/Agent 连接。 新建远程项目弹窗同样只读取本地 Host 验证记录,不扫描所有 Host;浏览目录或保存时才连接 所选 Host。版本号相同时仍允许用户显式重新安装,以修复 registry、签名或 identity 异常, diff --git a/src/renderer/src/SshHostsSettingsSection.test.tsx b/src/renderer/src/SshHostsSettingsSection.test.tsx index fb0b328f..5ee12ebb 100644 --- a/src/renderer/src/SshHostsSettingsSection.test.tsx +++ b/src/renderer/src/SshHostsSettingsSection.test.tsx @@ -749,6 +749,23 @@ describe('SshHostsSettingsSection', () => { ).not.toBeNull() }) + it('keeps the last successful version check when the settings page remounts', async () => { + getSnapshot.mockResolvedValue(verifiedSnapshot) + const first = render() + + await refreshHostEnvironment() + expect(getRemoteEnvironment).toHaveBeenCalledOnce() + first.unmount() + + render() + const host = await screen.findByRole('region', { + name: 'Build host' + }) + + expect(within(host).getByText('GoodBuddy Agent')).toBeInTheDocument() + expect(getRemoteEnvironment).toHaveBeenCalledOnce() + }) + it('does not contact any verified Host when the section opens', async () => { getSnapshot.mockResolvedValue({ hosts: [ diff --git a/src/renderer/src/SshHostsSettingsSection.tsx b/src/renderer/src/SshHostsSettingsSection.tsx index 6ac490d0..c1660a43 100644 --- a/src/renderer/src/SshHostsSettingsSection.tsx +++ b/src/renderer/src/SshHostsSettingsSection.tsx @@ -31,6 +31,11 @@ import type { AppNotificationInput } from './notifications' import { SettingsCategoryHeader } from './SettingsPrimitives' import { displayErrorMessage } from './error-message' import { SshHostDialog } from './SshHostDialog' +import { + getCachedSshHostRemoteEnvironments, + removeCachedSshHostRemoteEnvironment, + setCachedSshHostRemoteEnvironment +} from './ssh-host-remote-environment-cache' import { DestructiveConfirmActions, EmptyState, @@ -112,7 +117,16 @@ export function SshHostsSettingsSection({ >({}) const [remoteEnvironments, setRemoteEnvironments] = useState< Record - >({}) + >(() => + Object.fromEntries( + Object.entries(getCachedSshHostRemoteEnvironments()).map( + ([hostId, value]) => [ + hostId, + { loading: false, value } + ] + ) + ) + ) const [ remoteEnvironmentPreparationMethods, setRemoteEnvironmentPreparationMethods @@ -252,6 +266,7 @@ export function SshHostsSettingsSection({ ...current, [hostId]: { loading: false, value } })) + setCachedSshHostRemoteEnvironment(value) } catch (reason) { if ( !mounted.current || @@ -285,6 +300,7 @@ export function SshHostsSettingsSection({ ) { return } + removeCachedSshHostRemoteEnvironment(host.id) const requestId = remoteEnvironmentUpdateRequestId.current + 1 remoteEnvironmentUpdateRequestId.current = requestId const activeUpdate: ActiveRemoteEnvironmentUpdate = { @@ -474,6 +490,7 @@ export function SshHostsSettingsSection({ const handleSaved = (result: SshHostValidationResult): void => { const edited = hosts.some((host) => host.id === result.host.id) + removeCachedSshHostRemoteEnvironment(result.host.id) setHosts((current) => upsertHost(current, result.host)) setTestResults((current) => ({ ...current, @@ -500,6 +517,7 @@ export function SshHostsSettingsSection({ result.deletedProjects.map((project) => project.id) ) setConfirmingDeleteId(undefined) + removeCachedSshHostRemoteEnvironment(host.id) setHosts((current) => current.filter((candidate) => candidate.id !== host.id) ) diff --git a/src/renderer/src/ssh-host-remote-environment-cache.test.ts b/src/renderer/src/ssh-host-remote-environment-cache.test.ts new file mode 100644 index 00000000..64846bfe --- /dev/null +++ b/src/renderer/src/ssh-host-remote-environment-cache.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import type { SshHostRemoteEnvironment } from '../../shared/ssh-host-contracts' +import { SshHostRemoteEnvironmentCache } from './ssh-host-remote-environment-cache' + +const hostId = '00000000-0000-4000-8000-000000000104' +const version = { + version: '1.2.3', + architecture: 'x64' as const +} +const remoteEnvironment: SshHostRemoteEnvironment = { + hostId, + checkedAt: '2026-08-31T00:00:00.000Z', + architecture: 'x64', + agent: { + state: 'current', + expected: version, + installed: version + }, + runtimes: [ + { + runtimeId: 'opencode', + provider: 'opencode', + state: 'current', + expected: version, + installed: version + } + ], + remoteDownload: { + available: true, + source: 'mirror', + packageSize: 1024 + } +} + +describe('SshHostRemoteEnvironmentCache', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('restores successful checks in a new cache instance', () => { + const first = new SshHostRemoteEnvironmentCache(localStorage) + first.set(remoteEnvironment) + + const restarted = new SshHostRemoteEnvironmentCache(localStorage) + expect(restarted.getAll()).toEqual({ + [hostId]: remoteEnvironment + }) + }) + + it('persists Host invalidation across cache instances', () => { + const first = new SshHostRemoteEnvironmentCache(localStorage) + first.set(remoteEnvironment) + first.remove(hostId) + + const restarted = new SshHostRemoteEnvironmentCache(localStorage) + expect(restarted.getAll()).toEqual({}) + }) + + it('ignores malformed persisted values', () => { + localStorage.setItem( + 'goodbuddy.ssh-host-remote-environments.v1', + JSON.stringify([ + remoteEnvironment, + { ...remoteEnvironment, checkedAt: 'not-a-date' } + ]) + ) + + const restarted = new SshHostRemoteEnvironmentCache(localStorage) + expect(restarted.getAll()).toEqual({ + [hostId]: remoteEnvironment + }) + }) +}) diff --git a/src/renderer/src/ssh-host-remote-environment-cache.ts b/src/renderer/src/ssh-host-remote-environment-cache.ts new file mode 100644 index 00000000..24640514 --- /dev/null +++ b/src/renderer/src/ssh-host-remote-environment-cache.ts @@ -0,0 +1,119 @@ +import { + SSH_HOST_LIMITS, + sshHostRemoteEnvironmentSchema, + type SshHostRemoteEnvironment +} from '../../shared/ssh-host-contracts' + +type CacheStorage = Pick< + Storage, + 'getItem' | 'removeItem' | 'setItem' +> + +const storageKey = 'goodbuddy.ssh-host-remote-environments.v1' +const maximumPersistedPayloadLength = 1_000_000 + +function getLocalStorage(): CacheStorage | undefined { + try { + return typeof window === 'undefined' ? undefined : window.localStorage + } catch { + return undefined + } +} + +export class SshHostRemoteEnvironmentCache { + private readonly remoteEnvironments = new Map< + string, + SshHostRemoteEnvironment + >() + + constructor( + private readonly storage: CacheStorage | undefined = getLocalStorage() + ) { + this.load() + } + + getAll(): Readonly> { + return Object.fromEntries(this.remoteEnvironments) + } + + set(environment: SshHostRemoteEnvironment): void { + this.remoteEnvironments.set(environment.hostId, environment) + this.persist() + } + + remove(hostId: string): void { + if (this.remoteEnvironments.delete(hostId)) { + this.persist() + } + } + + clear(): void { + this.remoteEnvironments.clear() + try { + this.storage?.removeItem(storageKey) + } catch { + // The in-memory cache remains usable when persistence is unavailable. + } + } + + private load(): void { + try { + const raw = this.storage?.getItem(storageKey) + if (!raw || raw.length > maximumPersistedPayloadLength) { + return + } + const parsed: unknown = JSON.parse(raw) + if (!Array.isArray(parsed)) { + return + } + for (const candidate of parsed.slice( + 0, + SSH_HOST_LIMITS.maximumHosts + )) { + const environment = + sshHostRemoteEnvironmentSchema.safeParse(candidate) + if (environment.success) { + this.remoteEnvironments.set( + environment.data.hostId, + environment.data + ) + } + } + } catch { + // Corrupt or inaccessible storage behaves like an empty cache. + } + } + + private persist(): void { + try { + this.storage?.setItem( + storageKey, + JSON.stringify([...this.remoteEnvironments.values()]) + ) + } catch { + // Version checks still remain cached for the current process. + } + } +} + +const cache = new SshHostRemoteEnvironmentCache() + +export function getCachedSshHostRemoteEnvironments(): Readonly< + Record +> { + return cache.getAll() +} + +export function setCachedSshHostRemoteEnvironment( + environment: SshHostRemoteEnvironment +): void { + cache.set(environment) +} + +export function removeCachedSshHostRemoteEnvironment(hostId: string): void { + cache.remove(hostId) +} + +export function clearSshHostRemoteEnvironmentCache(): void { + cache.clear() +} diff --git a/src/renderer/src/test-setup.ts b/src/renderer/src/test-setup.ts index 013ff0b2..0d735671 100644 --- a/src/renderer/src/test-setup.ts +++ b/src/renderer/src/test-setup.ts @@ -1,12 +1,14 @@ import '@testing-library/jest-dom/vitest' import { beforeEach, vi } from 'vitest' import i18n from './i18n' +import { clearSshHostRemoteEnvironmentCache } from './ssh-host-remote-environment-cache' if (typeof Element !== 'undefined') { Element.prototype.scrollTo = vi.fn() } beforeEach(async () => { + clearSshHostRemoteEnvironmentCache() if (typeof localStorage === 'undefined' || typeof document === 'undefined') { return }