fix: preserve remote environment checks across settings pages

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 版本检查结果现在会在设置页面切换和应用重启后保留,无需重复点击刷新版本。
This commit is contained in:
mesalogo
2026-08-31 01:07:52 +08:00
parent efd304fa1d
commit 8c580a9174
6 changed files with 233 additions and 2 deletions
@@ -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(<SshHostsSettingsSection />)
await refreshHostEnvironment()
expect(getRemoteEnvironment).toHaveBeenCalledOnce()
first.unmount()
render(<SshHostsSettingsSection />)
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: [
+19 -1
View File
@@ -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<string, RemoteEnvironmentLoadState>
>({})
>(() =>
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)
)
@@ -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
})
})
})
@@ -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<Record<string, SshHostRemoteEnvironment>> {
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<string, SshHostRemoteEnvironment>
> {
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()
}
+2
View File
@@ -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
}