fix(dsh): invalidate failed health checks and keep broken settings editable (#650)

This commit is contained in:
taobig 2026-09-29 23:00:52 -04:00
parent 6647312162
commit e8e227b54d
3 changed files with 117 additions and 5 deletions

View File

@ -33,7 +33,9 @@ public class DshManagementService {
}
public Map<String, Object> 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<String, Object> invalidConfigurationStatus() {
Map<String, Object> 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<String, Object> saveConfig(Map<String, String> values) {
configService.save(values);
return status();
@ -86,17 +105,22 @@ public class DshManagementService {
private Map<String, Object> 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<String, Object> 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<String, Object> 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(); }
}

View File

@ -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<String, Object> 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<String, Object> 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"));
}
}

View File

@ -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<HTMLInputElement>('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('已就绪')
})