mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-11-03 06:25:32 +08:00
fix(dsh): invalidate failed health checks and keep broken settings editable (#650)
This commit is contained in:
parent
6647312162
commit
e8e227b54d
@ -33,7 +33,9 @@ public class DshManagementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Map<String, Object> status() {
|
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());
|
boolean executableAvailable = isExecutable(configuration.executablePath());
|
||||||
// An empty managed key is valid: DshRuntimeService can reuse the
|
// An empty managed key is valid: DshRuntimeService can reuse the
|
||||||
// existing DeepSeek provider key. The page may still store a managed
|
// existing DeepSeek provider key. The page may still store a managed
|
||||||
@ -68,6 +70,23 @@ public class DshManagementService {
|
|||||||
return result;
|
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) {
|
public Map<String, Object> saveConfig(Map<String, String> values) {
|
||||||
configService.save(values);
|
configService.save(values);
|
||||||
return status();
|
return status();
|
||||||
@ -86,17 +105,22 @@ public class DshManagementService {
|
|||||||
|
|
||||||
private Map<String, Object> check(boolean task) {
|
private Map<String, Object> check(boolean task) {
|
||||||
DshGenerationStore.Lease lease = null;
|
DshGenerationStore.Lease lease = null;
|
||||||
|
String revision = "";
|
||||||
|
if (task) taskCheck = Map.of(); else handshake = Map.of();
|
||||||
try {
|
try {
|
||||||
if (configService.generations() != null) lease = configService.generations().acquireLease("health", "health");
|
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();
|
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));
|
Map<String, Object> result = new LinkedHashMap<>(task ? runtime.testTask(configuration) : runtime.testConnection(configuration));
|
||||||
result.put("configRevision", revision);
|
result.put("configRevision", revision);
|
||||||
if (task) taskCheck = Map.copyOf(result); else handshake = Map.copyOf(result);
|
if (task) taskCheck = Map.copyOf(result); else handshake = Map.copyOf(result);
|
||||||
return result;
|
return result;
|
||||||
} catch (Exception error) {
|
} 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(); }
|
} finally { if (lease != null) lease.close(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,7 +2,7 @@ import { afterEach, expect, it, vi } from 'vitest'
|
|||||||
import { createApp, nextTick } from 'vue'
|
import { createApp, nextTick } from 'vue'
|
||||||
import DshSettings from '../index.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('@/api', () => ({ dshApi: api }))
|
||||||
vi.mock('@/composables/useMcToast', () => ({ mcToast: { success: vi.fn(), error: vi.fn() } }))
|
vi.mock('@/composables/useMcToast', () => ({ mcToast: { success: vi.fn(), error: vi.fn() } }))
|
||||||
let unmount: (() => void) | undefined
|
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(host.querySelector('.error-card')?.textContent).toContain('SDK handshake failed')
|
||||||
expect(api.status).toHaveBeenCalledTimes(2)
|
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('已就绪')
|
||||||
|
})
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user