From e8e227b54d98cbc2aa72f14ef1c25178316561ee Mon Sep 17 00:00:00 2001 From: taobig Date: Tue, 29 Sep 2026 23:00:52 -0400 Subject: [PATCH] fix(dsh): invalidate failed health checks and keep broken settings editable (#650) --- .../dsh/management/DshManagementService.java | 32 +++++++-- .../management/DshManagementHealthTest.java | 69 +++++++++++++++++++ .../Dsh/__tests__/healthState.test.ts | 21 +++++- 3 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/management/DshManagementHealthTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshManagementService.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshManagementService.java index 4b958292..40fa5094 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshManagementService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshManagementService.java @@ -33,7 +33,9 @@ public class DshManagementService { } public Map status() { - DshRuntimeConfiguration configuration = configService.resolve(); + DshRuntimeConfiguration configuration; + try { configuration = configService.resolve(); } + catch (IllegalArgumentException invalid) { return invalidConfigurationStatus(); } boolean executableAvailable = isExecutable(configuration.executablePath()); // An empty managed key is valid: DshRuntimeService can reuse the // existing DeepSeek provider key. The page may still store a managed @@ -68,6 +70,23 @@ public class DshManagementService { return result; } + private Map invalidConfigurationStatus() { + Map result = new LinkedHashMap<>(); + result.put("state", DshManagementState.CONFIG_INVALID.name()); + result.put("installed", false); + result.put("enabled", settings.getBool(ENABLED_KEY, false)); + result.put("config", Map.of()); + // Keep masked raw fields editable even when the resolved configuration cannot be parsed. + result.put("managed", configService.managedValues()); + result.put("configRevision", configService.revision()); + result.put("fileCheck", Map.of("success", false)); + result.put("handshake", Map.of()); + result.put("taskCheck", Map.of()); + result.put("versionStatus", "UNVERIFIED_VERSION"); + result.put("checkedAt", Instant.now().toString()); + return result; + } + public Map saveConfig(Map values) { configService.save(values); return status(); @@ -86,17 +105,22 @@ public class DshManagementService { private Map check(boolean task) { DshGenerationStore.Lease lease = null; + String revision = ""; + if (task) taskCheck = Map.of(); else handshake = Map.of(); try { if (configService.generations() != null) lease = configService.generations().acquireLease("health", "health"); - String revision = Objects.toString(configService.revision(), "0"); + revision = Objects.toString(configService.revision(), "0"); DshRuntimeConfiguration configuration = configService.resolve(); - if (runtime == null) return Map.of("success", false, "message", "DSH health service unavailable"); + if (runtime == null) throw new IllegalStateException("DSH health service unavailable"); Map result = new LinkedHashMap<>(task ? runtime.testTask(configuration) : runtime.testConnection(configuration)); result.put("configRevision", revision); if (task) taskCheck = Map.copyOf(result); else handshake = Map.copyOf(result); return result; } catch (Exception error) { - return Map.of("success", false, "message", "DSH health check unavailable"); + Map failed = Map.of("success", false, "message", "DSH health check unavailable", + "configRevision", revision, "checkedAt", Instant.now().toString()); + if (task) taskCheck = failed; else handshake = failed; + return failed; } finally { if (lease != null) lease.close(); } } diff --git a/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/management/DshManagementHealthTest.java b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/management/DshManagementHealthTest.java new file mode 100644 index 00000000..c011acce --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/management/DshManagementHealthTest.java @@ -0,0 +1,69 @@ +package vip.mate.agent.runtime.dsh.management; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.test.util.ReflectionTestUtils; +import vip.mate.agent.runtime.dsh.DshRuntimeService; +import vip.mate.system.service.SystemSettingService; + +import java.nio.file.Path; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class DshManagementHealthTest { + private final DshRuntimeConfigService config = mock(DshRuntimeConfigService.class); + private final DshRuntimeService runtime = mock(DshRuntimeService.class); + private final DshManagementService management = new DshManagementService(config, + mock(DshArtifactInstaller.class), mock(SystemSettingService.class)); + + @BeforeEach + void setup() { + when(config.revision()).thenReturn("revision-1"); + when(config.resolve()).thenReturn(new DshRuntimeConfiguration("/bin/sh", "", + Path.of(System.getProperty("java.io.tmpdir")).toString(), "", "model", "secret")); + ReflectionTestUtils.setField(management, "runtime", runtime); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void exceptionDuringRecheckInvalidatesPreviousSuccess(boolean task) { + if (task) when(runtime.testTask(any())).thenReturn(Map.of("success", true)) + .thenThrow(new IllegalStateException("private credential")); + else when(runtime.testConnection(any())).thenReturn(Map.of("success", true)) + .thenThrow(new IllegalStateException("private credential")); + assertEquals(true, (task ? management.testTask() : management.testConnection()).get("success")); + Map failed = task ? management.testTask() : management.testConnection(); + assertEquals(false, failed.get("success")); + Map displayed = (Map) management.status().get(task ? "taskCheck" : "handshake"); + assertEquals(false, displayed.get("success")); + assertFalse(displayed.toString().contains("private credential")); + } + + @Test + void busyAdmissionClearsOldHandshakeWithoutStartingAnotherProcess() { + when(runtime.testConnection(any())).thenReturn(Map.of("success", true)); + management.testConnection(); + DshGenerationStore store = mock(DshGenerationStore.class); + when(config.generations()).thenReturn(store); + when(store.acquireLease("health", "health")).thenThrow(new IllegalStateException("dsh.home_busy")); + assertEquals(false, management.testConnection().get("success")); + Map displayed = (Map) management.status().get("handshake"); + assertNotEquals(true, displayed.get("success")); + verify(runtime, times(1)).testConnection(any()); + } + + @Test + void invalidStoredConfigStillExposesEditableMaskedValues() { + when(config.resolve()).thenThrow(new IllegalArgumentException("private malformed input")); + when(config.managedValues()).thenReturn(Map.of("dsh.patch_paths", "[", "dsh.api_key", "****")); + Map status = assertDoesNotThrow(management::status); + assertEquals("CONFIG_INVALID", status.get("state")); + assertEquals(Map.of("dsh.patch_paths", "[", "dsh.api_key", "****"), status.get("managed")); + assertFalse(status.toString().contains("private malformed input")); + assertEquals(false, management.verify().get("verified")); + } +} diff --git a/mateclaw-ui/src/views/Settings/Dsh/__tests__/healthState.test.ts b/mateclaw-ui/src/views/Settings/Dsh/__tests__/healthState.test.ts index 5e4b0b7f..90317855 100644 --- a/mateclaw-ui/src/views/Settings/Dsh/__tests__/healthState.test.ts +++ b/mateclaw-ui/src/views/Settings/Dsh/__tests__/healthState.test.ts @@ -2,7 +2,7 @@ import { afterEach, expect, it, vi } from 'vitest' import { createApp, nextTick } from 'vue' import DshSettings from '../index.vue' -const api = vi.hoisted(() => ({ status: vi.fn(), testConnection: vi.fn() })) +const api = vi.hoisted(() => ({ status: vi.fn(), testConnection: vi.fn(), saveConfig: vi.fn() })) vi.mock('@/api', () => ({ dshApi: api })) vi.mock('@/composables/useMcToast', () => ({ mcToast: { success: vi.fn(), error: vi.fn() } })) let unmount: (() => void) | undefined @@ -23,3 +23,22 @@ it('clears a previous successful handshake after a failed recheck', async () => expect(host.querySelector('.error-card')?.textContent).toContain('SDK handshake failed') expect(api.status).toHaveBeenCalledTimes(2) }) + +it('lets an administrator repair malformed stored patches', async () => { + api.status.mockReset() + api.status.mockResolvedValueOnce({ data: { state: 'CONFIG_INVALID', installed: false, config: {}, + managed: { 'dsh.patch_paths': '[', 'dsh.api_key': '****' } } }) + .mockResolvedValue({ data: { state: 'READY', installed: true, config: {}, managed: { 'dsh.patch_paths': '[]' } } }) + api.saveConfig.mockResolvedValue({ data: {} }) + const host = document.createElement('div'); document.body.append(host) + const app = createApp(DshSettings); app.mount(host); unmount = () => app.unmount() + await flush() + const patch = [...host.querySelectorAll('label')].find(item => item.textContent?.includes('Patch'))!.querySelector('input')! + expect(patch.value).toBe('[') + expect(host.querySelector('input[type="password"]')!.value).toBe('') + patch.value = '[]'; patch.dispatchEvent(new Event('input', { bubbles: true })); await nextTick() + ;[...host.querySelectorAll('button')].find(item => item.textContent === '保存配置')!.click() + await flush() + expect(api.saveConfig).toHaveBeenCalledWith(expect.objectContaining({ 'dsh.patch_paths': '[]' })) + expect(host.querySelector('.state-pill')?.textContent).toBe('已就绪') +})