mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(dsh): complete cancellable runtime stream lifecycle; make JSON-RPC streaming asynchronous; terminate process trees and inherited pipes; handle cancellation races; propagate usage telemetry; add regression coverage
This commit is contained in:
parent
5660c72e1a
commit
a8a0b75bfa
@ -3,9 +3,9 @@ package vip.mate.agent.runtime.dsh;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.agent.runtime.RuntimeEventProjector;
|
||||
import vip.mate.agent.runtime.contract.RuntimeEvent;
|
||||
@ -16,6 +16,8 @@ import vip.mate.agent.runtime.contract.AgentRuntimeProvider;
|
||||
import vip.mate.agent.runtime.contract.RuntimeCapabilities;
|
||||
import vip.mate.agent.runtime.contract.RuntimeContextUsage;
|
||||
import vip.mate.agent.runtime.contract.RuntimeValidation;
|
||||
import vip.mate.agent.runtime.dsh.management.DshRuntimeConfigService;
|
||||
import vip.mate.agent.runtime.dsh.management.DshRuntimeConfiguration;
|
||||
import vip.mate.agent.AgentService;
|
||||
import vip.mate.llm.model.ModelConfigEntity;
|
||||
import vip.mate.llm.model.ModelProviderEntity;
|
||||
@ -34,6 +36,7 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* Adapter for the official DeepSeek Harness SDK JSON-RPC runtime.
|
||||
@ -48,26 +51,30 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ModelConfigService modelConfigService;
|
||||
private final ModelProviderService modelProviderService;
|
||||
private final String runtimeCommand;
|
||||
private final String cordisConfig;
|
||||
private final DshRuntimeConfigService runtimeConfigService;
|
||||
|
||||
public DshRuntimeService(
|
||||
ObjectMapper objectMapper,
|
||||
ModelConfigService modelConfigService,
|
||||
ModelProviderService modelProviderService,
|
||||
@Value("${mateclaw.agent.runtime.dsh.command:}") String configuredCommand,
|
||||
@Value("${mateclaw.agent.runtime.dsh.cordis-config:}") String configuredCordisConfig) {
|
||||
DshRuntimeConfigService runtimeConfigService) {
|
||||
this.objectMapper = objectMapper;
|
||||
this.modelConfigService = modelConfigService;
|
||||
this.modelProviderService = modelProviderService;
|
||||
this.runtimeCommand = configuredCommand == null || configuredCommand.isBlank()
|
||||
? System.getenv().getOrDefault("DSH_JSONRPC_AGENT", "dsh-jsonrpc-agent")
|
||||
: configuredCommand.trim();
|
||||
this.cordisConfig = resolveCordisConfig(configuredCordisConfig == null || configuredCordisConfig.isBlank()
|
||||
? System.getenv().getOrDefault("DSH_CORDIS_CONFIG", "")
|
||||
: configuredCordisConfig.trim());
|
||||
log.info("[DSH] runtime configured: command={}, cordisConfig={}", runtimeCommand,
|
||||
cordisConfig.isBlank() ? "<empty>" : cordisConfig);
|
||||
this.runtimeConfigService = runtimeConfigService;
|
||||
DshRuntimeConfiguration configuration = runtimeConfig();
|
||||
log.info("[DSH] runtime configured: command={}, cordisConfig={}", configuration.executablePath(),
|
||||
configuration.cordisConfigPath().isBlank() ? "<empty>" : configuration.cordisConfigPath());
|
||||
}
|
||||
|
||||
private DshRuntimeConfiguration runtimeConfig() {
|
||||
DshRuntimeConfiguration raw = runtimeConfigService.resolve();
|
||||
String command = raw.executablePath();
|
||||
if (command == null || command.isBlank()) command = "dsh-jsonrpc-agent";
|
||||
String cordis = resolveCordisConfig(raw.cordisConfigPath());
|
||||
String cwd = raw.workingDirectory();
|
||||
if (cwd == null || cwd.isBlank()) cwd = System.getProperty("user.dir");
|
||||
return new DshRuntimeConfiguration(command, cordis, cwd, raw.baseUrl(), raw.modelName(), raw.apiKey());
|
||||
}
|
||||
|
||||
private String resolveCordisConfig(String configuredPath) {
|
||||
@ -90,20 +97,21 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
||||
|
||||
@Override
|
||||
public RuntimeValidation validate(RuntimeSession session) {
|
||||
DshRuntimeConfiguration configuration = runtimeConfig();
|
||||
if (session == null || session.workspaceId() == null) {
|
||||
return RuntimeValidation.invalid("dsh.workspace_required", "DSH runtime requires a workspace");
|
||||
}
|
||||
if (session.workingDirectory() == null || !Files.isDirectory(session.workingDirectory())) {
|
||||
return RuntimeValidation.invalid("dsh.working_directory_unavailable", "DSH working directory is unavailable");
|
||||
}
|
||||
if (runtimeCommand.isBlank()) {
|
||||
if (configuration.executablePath().isBlank()) {
|
||||
return RuntimeValidation.invalid("dsh.command_missing", "DSH runtime command is not configured");
|
||||
}
|
||||
Path executable = Path.of(commandLine().get(0));
|
||||
Path executable = Path.of(commandLine(configuration.executablePath()).get(0));
|
||||
if (!executable.isAbsolute() || !Files.isExecutable(executable)) {
|
||||
return RuntimeValidation.invalid("dsh.command_unavailable", "DSH runtime command is not executable");
|
||||
}
|
||||
if (!cordisConfig.isBlank() && !Files.isRegularFile(Path.of(cordisConfig))) {
|
||||
if (!configuration.cordisConfigPath().isBlank() && !Files.isRegularFile(Path.of(configuration.cordisConfigPath()))) {
|
||||
return RuntimeValidation.invalid("dsh.cordis_missing", "DSH Cordis configuration is unavailable");
|
||||
}
|
||||
return RuntimeValidation.success();
|
||||
@ -115,15 +123,18 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
||||
}
|
||||
|
||||
public Map<String, Object> diagnostics() {
|
||||
Path executable = runtimeCommand.isBlank() ? null : Path.of(commandLine().get(0));
|
||||
DshRuntimeConfiguration configuration = runtimeConfig();
|
||||
Path executable = configuration.executablePath().isBlank() ? null : Path.of(commandLine(configuration.executablePath()).get(0));
|
||||
return Map.of(
|
||||
"type", type(),
|
||||
"commandConfigured", !runtimeCommand.isBlank(),
|
||||
"command", runtimeCommand,
|
||||
"commandConfigured", !configuration.executablePath().isBlank(),
|
||||
"command", configuration.executablePath(),
|
||||
"executable", executable == null ? "" : executable.toString(),
|
||||
"executableAvailable", executable != null && Files.isExecutable(executable),
|
||||
"cordisConfig", cordisConfig,
|
||||
"cordisConfigAvailable", !cordisConfig.isBlank() && Files.isRegularFile(Path.of(cordisConfig)),
|
||||
"cordisConfig", configuration.cordisConfigPath(),
|
||||
"cordisConfigAvailable", !configuration.cordisConfigPath().isBlank() && Files.isRegularFile(Path.of(configuration.cordisConfigPath())),
|
||||
"workingDirectory", configuration.workingDirectory(),
|
||||
"apiKeyConfigured", configuration.apiKey() != null && !configuration.apiKey().isBlank(),
|
||||
"capabilities", Map.of(
|
||||
"cancellation", true,
|
||||
"approvals", false,
|
||||
@ -155,21 +166,26 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
||||
agent.setId(session.agentId());
|
||||
agent.setWorkspaceId(session.workspaceId());
|
||||
agent.setModelName(session.modelName());
|
||||
AtomicReference<Process> activeProcess = new AtomicReference<>();
|
||||
AtomicReference<RuntimeContextUsage> latestUsage = new AtomicReference<>(
|
||||
new RuntimeContextUsage(0, 0, 0));
|
||||
return new AgentRuntimeConnection() {
|
||||
@Override
|
||||
public Flux<RuntimeEvent> prompt(String message) {
|
||||
return stream(agent, message, session.conversationId(), session.modelName())
|
||||
return stream(agent, message, session.conversationId(), session.modelName(),
|
||||
session.workingDirectory(), activeProcess, latestUsage)
|
||||
.map(DshRuntimeService.this::toRuntimeEvent);
|
||||
}
|
||||
|
||||
@Override
|
||||
public reactor.core.publisher.Mono<Void> cancel() {
|
||||
return reactor.core.publisher.Mono.empty();
|
||||
return reactor.core.publisher.Mono.fromRunnable(
|
||||
() -> cancelProcess(activeProcess.get()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public reactor.core.publisher.Mono<RuntimeContextUsage> contextUsage() {
|
||||
return reactor.core.publisher.Mono.just(new RuntimeContextUsage(0, 0, 0));
|
||||
return reactor.core.publisher.Mono.just(latestUsage.get());
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -198,31 +214,45 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
||||
|
||||
public Flux<AgentService.StreamDelta> stream(AgentEntity agent, String message,
|
||||
String conversationId, String modelName) {
|
||||
return Flux.create(sink -> {
|
||||
DshRuntimeConfiguration configuration = runtimeConfig();
|
||||
return stream(agent, message, conversationId, modelName,
|
||||
resolveWorkingDirectory(null, configuration), new AtomicReference<>(),
|
||||
new AtomicReference<>(new RuntimeContextUsage(0, 0, 0)));
|
||||
}
|
||||
|
||||
private Flux<AgentService.StreamDelta> stream(AgentEntity agent, String message,
|
||||
String conversationId, String modelName,
|
||||
Path workingDirectory,
|
||||
AtomicReference<Process> processRef,
|
||||
AtomicReference<RuntimeContextUsage> latestUsage) {
|
||||
return Flux.<AgentService.StreamDelta>create(sink -> {
|
||||
Process process = null;
|
||||
try {
|
||||
if (sink.isCancelled()) return;
|
||||
DshRuntimeConfiguration configuration = runtimeConfig();
|
||||
RuntimeSession session = new RuntimeSession(
|
||||
conversationId,
|
||||
conversationId,
|
||||
agent.getId(),
|
||||
agent.getWorkspaceId(),
|
||||
modelName,
|
||||
Path.of(System.getenv().getOrDefault("DSH_CWD", System.getProperty("user.dir"))),
|
||||
workingDirectory,
|
||||
Map.of());
|
||||
// Each prompt runs in a fresh child process. DSH persists its
|
||||
// own session log, so reusing the MateClaw conversation id
|
||||
// would make the next turn look like a conflicting live session.
|
||||
String dshSessionId = conversationId + "-" + UUID.randomUUID();
|
||||
Files.createDirectories(session.workingDirectory());
|
||||
ModelProviderEntity provider = resolveProvider(modelName);
|
||||
String effectiveModelName = resolveModelName(modelName);
|
||||
String requestedModel = modelName == null || modelName.isBlank() ? configuration.modelName() : modelName;
|
||||
ModelProviderEntity provider = resolveProvider(requestedModel);
|
||||
String effectiveModelName = resolveModelName(requestedModel);
|
||||
log.debug("[DSH] model route: requestedModel={}, effectiveModel={}, provider={}, apiKeyConfigured={}, baseUrlConfigured={}",
|
||||
modelName == null || modelName.isBlank() ? "<default>" : modelName,
|
||||
effectiveModelName,
|
||||
provider == null ? "<missing>" : provider.getProviderId(),
|
||||
provider != null && provider.getApiKey() != null && !provider.getApiKey().isBlank(),
|
||||
provider != null && provider.getBaseUrl() != null && !provider.getBaseUrl().isBlank());
|
||||
List<String> command = commandLine();
|
||||
List<String> command = commandLine(configuration.executablePath());
|
||||
ProcessBuilder builder = new ProcessBuilder(command)
|
||||
.directory(session.workingDirectory().toFile())
|
||||
.redirectError(ProcessBuilder.Redirect.PIPE);
|
||||
@ -230,33 +260,40 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
||||
// The packaged binary gives the environment variable precedence
|
||||
// over argv. Set the resolved path explicitly so IDEA/.env
|
||||
// inheritance cannot select a different composition.
|
||||
if (!cordisConfig.isBlank()) {
|
||||
builder.environment().put("DSH_CORDIS_CONFIG", cordisConfig);
|
||||
if (!configuration.cordisConfigPath().isBlank()) {
|
||||
builder.environment().put("DSH_CORDIS_CONFIG", configuration.cordisConfigPath());
|
||||
} else {
|
||||
builder.environment().remove("DSH_CORDIS_CONFIG");
|
||||
}
|
||||
log.debug("[DSH] child environment: cordisConfig={}, exists={}",
|
||||
builder.environment().getOrDefault("DSH_CORDIS_CONFIG", "<empty>"),
|
||||
!cordisConfig.isBlank() && Files.isRegularFile(Path.of(cordisConfig)));
|
||||
if (provider != null) {
|
||||
if (provider.getApiKey() != null && !provider.getApiKey().isBlank()) {
|
||||
builder.environment().put("DEEPSEEK_API_KEY", provider.getApiKey());
|
||||
}
|
||||
if (provider.getBaseUrl() != null && !provider.getBaseUrl().isBlank()) {
|
||||
builder.environment().put("DEEPSEEK_BASE_URL", provider.getBaseUrl());
|
||||
}
|
||||
!configuration.cordisConfigPath().isBlank() && Files.isRegularFile(Path.of(configuration.cordisConfigPath())));
|
||||
String apiKey = configuration.apiKey();
|
||||
if ((apiKey == null || apiKey.isBlank()) && provider != null) apiKey = provider.getApiKey();
|
||||
if (apiKey != null && !apiKey.isBlank()) {
|
||||
builder.environment().put("DEEPSEEK_API_KEY", apiKey);
|
||||
}
|
||||
String baseUrl = configuration.baseUrl();
|
||||
if ((baseUrl == null || baseUrl.isBlank()) && provider != null) baseUrl = provider.getBaseUrl();
|
||||
if (baseUrl != null && !baseUrl.isBlank()) {
|
||||
builder.environment().put("DEEPSEEK_BASE_URL", baseUrl);
|
||||
}
|
||||
process = builder.start();
|
||||
Process activeProcess = process;
|
||||
Thread stderrLogger = new Thread(() -> logProcessStderr(activeProcess),
|
||||
processRef.set(process);
|
||||
if (sink.isCancelled()) {
|
||||
cancelProcess(process);
|
||||
return;
|
||||
}
|
||||
Process startedProcess = process;
|
||||
Thread stderrLogger = new Thread(() -> logProcessStderr(startedProcess),
|
||||
"dsh-runtime-stderr-" + conversationId);
|
||||
stderrLogger.setDaemon(true);
|
||||
stderrLogger.start();
|
||||
sink.onCancel(() -> activeProcess.destroyForcibly());
|
||||
sink.onCancel(() -> cancelProcess(startedProcess));
|
||||
try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(
|
||||
activeProcess.getOutputStream(), StandardCharsets.UTF_8));
|
||||
process.getOutputStream(), StandardCharsets.UTF_8));
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(
|
||||
activeProcess.getInputStream(), StandardCharsets.UTF_8))) {
|
||||
process.getInputStream(), StandardCharsets.UTF_8))) {
|
||||
send(writer, request("initialize", "init-" + conversationId, Map.of(
|
||||
"cwd", session.workingDirectory().toString(),
|
||||
"provider", "deepseek-official",
|
||||
@ -265,7 +302,7 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
||||
long sequence = 0;
|
||||
sink.next(RuntimeEventProjector.project(RuntimeEvent.of(
|
||||
conversationId, sequence++, RuntimeEventType.RUNTIME_READY, null,
|
||||
Map.of("runtimeProvider", "dsh", "runtimeCommand", runtimeCommand))));
|
||||
Map.of("runtimeProvider", "dsh", "runtimeCommand", configuration.executablePath()))));
|
||||
|
||||
String promptId = "prompt-" + conversationId;
|
||||
send(writer, request("session/prompt", promptId, Map.of(
|
||||
@ -305,6 +342,9 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
||||
logTerminalReason(event);
|
||||
RuntimeEvent mapped = mapEvent(conversationId, sequence++, event);
|
||||
if (mapped != null) {
|
||||
if (mapped.type() == RuntimeEventType.CONTEXT_USAGE) {
|
||||
latestUsage.set(usageFrom(mapped));
|
||||
}
|
||||
sink.next(RuntimeEventProjector.project(mapped));
|
||||
terminal = mapped.terminal();
|
||||
}
|
||||
@ -318,7 +358,7 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
||||
}
|
||||
}
|
||||
if (!terminal) {
|
||||
int exitCode = activeProcess.waitFor();
|
||||
int exitCode = process.waitFor();
|
||||
sink.next(RuntimeEventProjector.project(RuntimeEvent.terminal(
|
||||
conversationId, sequence, RuntimeEventType.FAILED,
|
||||
Map.of("error", "DSH runtime closed before completion (exit=" + exitCode + ")"))));
|
||||
@ -328,8 +368,54 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
||||
} catch (Exception error) {
|
||||
sink.error(new IllegalStateException("DSH runtime unavailable: " + error.getMessage(), error));
|
||||
if (process != null) process.destroyForcibly();
|
||||
} finally {
|
||||
if (process != null) processRef.compareAndSet(process, null);
|
||||
}
|
||||
});
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
static Path resolveWorkingDirectory(RuntimeSession session, DshRuntimeConfiguration configuration) {
|
||||
if (session != null && session.workingDirectory() != null) {
|
||||
return session.workingDirectory().toAbsolutePath().normalize();
|
||||
}
|
||||
return Path.of(configuration.workingDirectory()).toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
static void cancelProcess(Process process) {
|
||||
if (process == null || !process.isAlive()) return;
|
||||
|
||||
// DSH tools can spawn commands such as `sleep` that inherit the
|
||||
// JSON-RPC process' stdout pipe. Close the pipes and terminate the
|
||||
// descendants first; otherwise the parent may die while readLine()
|
||||
// remains blocked until the child exits naturally.
|
||||
try {
|
||||
var descendants = process.descendants();
|
||||
if (descendants != null) {
|
||||
descendants.toList().forEach(DshRuntimeService::cancelProcessHandle);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// The parent teardown below is still the best-effort fallback.
|
||||
}
|
||||
closeQuietly(process.getInputStream());
|
||||
closeQuietly(process.getErrorStream());
|
||||
closeQuietly(process.getOutputStream());
|
||||
process.destroy();
|
||||
if (process.isAlive()) process.destroyForcibly();
|
||||
}
|
||||
|
||||
private static void cancelProcessHandle(ProcessHandle process) {
|
||||
if (process == null || !process.isAlive()) return;
|
||||
process.destroy();
|
||||
if (process.isAlive()) process.destroyForcibly();
|
||||
}
|
||||
|
||||
private static void closeQuietly(java.io.Closeable stream) {
|
||||
if (stream == null) return;
|
||||
try {
|
||||
stream.close();
|
||||
} catch (Exception ignored) {
|
||||
// Cancellation is best effort; the process termination is authoritative.
|
||||
}
|
||||
}
|
||||
|
||||
private void logProcessStderr(Process process) {
|
||||
@ -344,10 +430,34 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> commandLine() {
|
||||
String[] parts = runtimeCommand.trim().split("\\s+");
|
||||
static List<String> commandLine(String commandLine) {
|
||||
List<String> result = new ArrayList<>();
|
||||
for (String part : parts) if (!part.isBlank()) result.add(part);
|
||||
StringBuilder token = new StringBuilder();
|
||||
char quote = 0;
|
||||
boolean escaped = false;
|
||||
for (char current : commandLine == null ? "".toCharArray() : commandLine.toCharArray()) {
|
||||
if (escaped) {
|
||||
token.append(current);
|
||||
escaped = false;
|
||||
} else if (current == '\\') {
|
||||
escaped = true;
|
||||
} else if (quote != 0) {
|
||||
if (current == quote) quote = 0;
|
||||
else token.append(current);
|
||||
} else if (current == '\'' || current == '"') {
|
||||
quote = current;
|
||||
} else if (Character.isWhitespace(current)) {
|
||||
if (!token.isEmpty()) {
|
||||
result.add(token.toString());
|
||||
token.setLength(0);
|
||||
}
|
||||
} else {
|
||||
token.append(current);
|
||||
}
|
||||
}
|
||||
if (escaped) token.append('\\');
|
||||
if (quote != 0) throw new IllegalArgumentException("DSH runtime command has an unterminated quote");
|
||||
if (!token.isEmpty()) result.add(token.toString());
|
||||
if (result.isEmpty()) throw new IllegalStateException("DSH runtime command is empty");
|
||||
log.debug("[DSH] launching command: {}", result);
|
||||
return result;
|
||||
@ -386,11 +496,22 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
||||
return modelName == null || modelName.isBlank() ? "deepseek-v4-flash" : modelName;
|
||||
}
|
||||
|
||||
private RuntimeEvent mapEvent(String sessionId, long sequence, JsonNode event) {
|
||||
RuntimeEvent mapEvent(String sessionId, long sequence, JsonNode event) {
|
||||
String type = event.path("type").asText("");
|
||||
JsonNode data = event.path("data");
|
||||
if ("assistant/chunk".equals(type)) {
|
||||
JsonNode chunk = data.has("chunk") ? data.path("chunk") : data;
|
||||
if ("usage".equals(chunk.path("type").asText())) {
|
||||
JsonNode usage = chunk.path("usage");
|
||||
long inputTokens = usage.path("inputTokens").asLong(0);
|
||||
long outputTokens = usage.path("outputTokens").asLong(0);
|
||||
return RuntimeEvent.of(sessionId, sequence, RuntimeEventType.CONTEXT_USAGE,
|
||||
null, Map.of(
|
||||
"promptTokens", inputTokens,
|
||||
"completionTokens", outputTokens,
|
||||
"inputTokens", inputTokens,
|
||||
"outputTokens", outputTokens));
|
||||
}
|
||||
String text = firstText(chunk, data);
|
||||
if (text != null && !text.isEmpty()) {
|
||||
RuntimeEventType eventType = "reasoning-delta".equals(chunk.path("type").asText())
|
||||
@ -432,6 +553,17 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
||||
return null;
|
||||
}
|
||||
|
||||
private RuntimeContextUsage usageFrom(RuntimeEvent event) {
|
||||
return new RuntimeContextUsage(
|
||||
number(event.data().get("inputTokens")),
|
||||
number(event.data().get("outputTokens")),
|
||||
number(event.data().get("contextWindow")));
|
||||
}
|
||||
|
||||
private long number(Object value) {
|
||||
return value instanceof Number number ? Math.max(0, number.longValue()) : 0;
|
||||
}
|
||||
|
||||
private String firstText(JsonNode primary, JsonNode fallback) {
|
||||
String text = primary.path("text").asText(null);
|
||||
if (text != null) return text;
|
||||
|
||||
@ -0,0 +1,200 @@
|
||||
package vip.mate.agent.runtime.dsh.management;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/** Downloads and atomically installs the server-selected DSH artifact. */
|
||||
@Service
|
||||
public class DshArtifactInstaller {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final HttpClient httpClient;
|
||||
private final URI manifestUri;
|
||||
private final URI githubReleaseUri;
|
||||
private final Path installRoot;
|
||||
|
||||
public DshArtifactInstaller(
|
||||
ObjectMapper objectMapper,
|
||||
@Value("${mateclaw.agent.runtime.dsh.manifest-url:}") String manifestUrl,
|
||||
@Value("${mateclaw.agent.runtime.dsh.github-release-url:https://api.github.com/repos/deepseek-ai/deepseek-harness/releases/latest}") String githubReleaseUrl,
|
||||
@Value("${mateclaw.agent.runtime.dsh.install-root:${user.home}/.mateclaw/runtimes/deepseek-harness}") String installRoot) {
|
||||
this.objectMapper = objectMapper;
|
||||
this.httpClient = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build();
|
||||
this.manifestUri = manifestUrl == null || manifestUrl.isBlank() ? null : URI.create(manifestUrl.trim());
|
||||
this.githubReleaseUri = URI.create(githubReleaseUrl.trim());
|
||||
this.installRoot = Path.of(installRoot).toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
public boolean isInstalled() {
|
||||
return Files.isExecutable(installRoot.resolve("dsh-jsonrpc-agent"))
|
||||
|| Files.isExecutable(installRoot.resolve("bin/dsh-jsonrpc-agent"));
|
||||
}
|
||||
|
||||
public boolean manifestConfigured() {
|
||||
return manifestUri != null || githubReleaseUri != null;
|
||||
}
|
||||
|
||||
public boolean privateManifestConfigured() {
|
||||
return manifestUri != null;
|
||||
}
|
||||
|
||||
public Path installedExecutable() {
|
||||
Path direct = installRoot.resolve("dsh-jsonrpc-agent");
|
||||
return Files.isExecutable(direct) ? direct : installRoot.resolve("bin/dsh-jsonrpc-agent");
|
||||
}
|
||||
|
||||
public Path installedCordisConfig() {
|
||||
if (!Files.exists(installRoot)) return null;
|
||||
try (var paths = Files.walk(installRoot)) {
|
||||
return paths.filter(path -> path.getFileName().toString().equals("cordis.yml"))
|
||||
.findFirst().orElse(null);
|
||||
} catch (Exception ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public DshArtifactManifest loadManifest() throws Exception {
|
||||
if (manifestUri != null) {
|
||||
HttpRequest request = HttpRequest.newBuilder(manifestUri).GET().build();
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if (response.statusCode() / 100 == 2) return objectMapper.readValue(response.body(), DshArtifactManifest.class);
|
||||
}
|
||||
return loadGithubManifest();
|
||||
}
|
||||
|
||||
private DshArtifactManifest loadGithubManifest() throws Exception {
|
||||
HttpRequest request = HttpRequest.newBuilder(githubReleaseUri)
|
||||
.header("Accept", "application/vnd.github+json").GET().build();
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if (response.statusCode() / 100 != 2) throw new IllegalStateException("DSH private manifest unavailable and GitHub fallback failed: HTTP " + response.statusCode());
|
||||
JsonNode root = objectMapper.readTree(response.body());
|
||||
for (JsonNode asset : root.path("assets")) {
|
||||
String name = asset.path("name").asText("").toLowerCase();
|
||||
String digest = asset.path("digest").asText("");
|
||||
if ((name.contains("macos") || name.contains("darwin")) && name.contains("arm64") && digest.startsWith("sha256:")) {
|
||||
return new DshArtifactManifest("deepseek-harness", root.path("tag_name").asText("latest"), "macos-arm64",
|
||||
asset.path("browser_download_url").asText(), digest.substring("sha256:".length()), asset.path("size").asLong(0), null);
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("GitHub DSH release has no macos-arm64 asset with a SHA-256 digest");
|
||||
}
|
||||
|
||||
public Path install(DshArtifactManifest manifest) throws Exception {
|
||||
validateManifest(manifest);
|
||||
Path parent = installRoot.getParent();
|
||||
Files.createDirectories(parent);
|
||||
Path archive = Files.createTempFile(parent, ".dsh-download-", ".tar.gz");
|
||||
Path staging = Files.createTempDirectory(parent, ".dsh-staging-");
|
||||
try {
|
||||
HttpRequest request = HttpRequest.newBuilder(URI.create(manifest.downloadUrl())).GET().build();
|
||||
HttpResponse<InputStream> response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
|
||||
if (response.statusCode() / 100 != 2) throw new IllegalStateException("DSH artifact request failed: HTTP " + response.statusCode());
|
||||
try (InputStream input = response.body()) {
|
||||
Files.copy(input, archive, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
if (manifest.size() > 0 && Files.size(archive) != manifest.size()) {
|
||||
throw new IllegalStateException("DSH artifact size mismatch");
|
||||
}
|
||||
verifyChecksum(archive, manifest.sha256());
|
||||
verifyArchiveEntries(archive);
|
||||
runTar(archive, staging);
|
||||
verifyExtractedTree(staging);
|
||||
Path executable = findExecutable(staging);
|
||||
Path executableRelativePath = staging.relativize(executable);
|
||||
executable.toFile().setExecutable(true, false);
|
||||
Path backup = parent.resolve(".dsh-previous");
|
||||
if (Files.exists(installRoot)) Files.move(installRoot, backup, StandardCopyOption.REPLACE_EXISTING);
|
||||
Files.move(staging, installRoot, StandardCopyOption.ATOMIC_MOVE);
|
||||
Files.deleteIfExists(backup);
|
||||
return installRoot.resolve(executableRelativePath);
|
||||
} finally {
|
||||
Files.deleteIfExists(archive);
|
||||
deleteTree(staging);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateManifest(DshArtifactManifest manifest) {
|
||||
if (manifest == null || manifest.downloadUrl() == null || manifest.downloadUrl().isBlank()
|
||||
|| manifest.sha256() == null || !manifest.sha256().matches("[0-9a-fA-F]{64}")) {
|
||||
throw new IllegalArgumentException("DSH artifact manifest is incomplete or has an invalid checksum");
|
||||
}
|
||||
URI uri = URI.create(manifest.downloadUrl());
|
||||
if (!"https".equalsIgnoreCase(uri.getScheme())) throw new IllegalArgumentException("DSH artifact must use HTTPS");
|
||||
}
|
||||
|
||||
private void verifyChecksum(Path archive, String expected) throws Exception {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
try (InputStream input = Files.newInputStream(archive)) {
|
||||
input.transferTo(new java.security.DigestOutputStream(OutputStreamDiscard.INSTANCE, digest));
|
||||
}
|
||||
String actual = HexFormat.of().formatHex(digest.digest());
|
||||
if (!actual.equalsIgnoreCase(expected)) throw new IllegalStateException("DSH artifact checksum mismatch");
|
||||
}
|
||||
|
||||
private void verifyArchiveEntries(Path archive) throws Exception {
|
||||
Process process = new ProcessBuilder("tar", "-tzf", archive.toString()).redirectErrorStream(true).start();
|
||||
List<String> entries;
|
||||
try (InputStream input = process.getInputStream()) {
|
||||
entries = new String(input.readAllBytes(), StandardCharsets.UTF_8).lines().toList();
|
||||
}
|
||||
if (!process.waitFor(30, TimeUnit.SECONDS) || process.exitValue() != 0) throw new IllegalStateException("DSH archive is not a readable tar.gz");
|
||||
for (String entry : entries) {
|
||||
Path normalized = Path.of(entry).normalize();
|
||||
if (entry.startsWith("/") || normalized.startsWith("..")) throw new IllegalArgumentException("DSH archive contains an unsafe path");
|
||||
}
|
||||
}
|
||||
|
||||
private void runTar(Path archive, Path destination) throws Exception {
|
||||
Process process = new ProcessBuilder("tar", "-xzf", archive.toString(), "-C", destination.toString()).redirectErrorStream(true).start();
|
||||
String output;
|
||||
try (InputStream input = process.getInputStream()) { output = new String(input.readAllBytes(), StandardCharsets.UTF_8); }
|
||||
if (!process.waitFor(60, TimeUnit.SECONDS) || process.exitValue() != 0) throw new IllegalStateException("DSH archive extraction failed: " + output);
|
||||
}
|
||||
|
||||
private Path findExecutable(Path staging) throws Exception {
|
||||
try (var paths = Files.walk(staging)) {
|
||||
return paths.filter(path -> path.getFileName().toString().equals("dsh-jsonrpc-agent"))
|
||||
.findFirst().orElseThrow(() -> new IllegalStateException("DSH artifact has no dsh-jsonrpc-agent executable"));
|
||||
}
|
||||
}
|
||||
|
||||
private void verifyExtractedTree(Path staging) throws Exception {
|
||||
try (var paths = Files.walk(staging)) {
|
||||
for (Path path : paths.toList()) {
|
||||
if (!Files.isSymbolicLink(path)) continue;
|
||||
Path target = path.getParent().resolve(Files.readSymbolicLink(path)).normalize();
|
||||
if (!target.startsWith(staging)) throw new IllegalArgumentException("DSH archive contains a link outside its staging directory");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteTree(Path root) throws Exception {
|
||||
if (root == null || !Files.exists(root)) return;
|
||||
try (var paths = Files.walk(root)) {
|
||||
paths.sorted(java.util.Comparator.reverseOrder()).forEach(path -> {
|
||||
try { Files.deleteIfExists(path); } catch (Exception ignored) { }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static final class OutputStreamDiscard extends java.io.OutputStream {
|
||||
private static final OutputStreamDiscard INSTANCE = new OutputStreamDiscard();
|
||||
@Override public void write(int b) { }
|
||||
@Override public void write(byte[] b, int off, int len) { }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
package vip.mate.agent.runtime.dsh.management;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record DshArtifactManifest(
|
||||
String name,
|
||||
String version,
|
||||
String platform,
|
||||
String downloadUrl,
|
||||
String sha256,
|
||||
long size,
|
||||
Instant releasedAt) {
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
package vip.mate.agent.runtime.dsh.management;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import vip.mate.common.result.R;
|
||||
import vip.mate.workspace.core.annotation.RequireGlobalAdmin;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Tag(name = "DeepSeek Harness Runtime Management")
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/dsh")
|
||||
@RequiredArgsConstructor
|
||||
public class DshManagementController {
|
||||
private final DshManagementService managementService;
|
||||
|
||||
@Operation(summary = "Get managed DSH runtime status")
|
||||
@GetMapping("/status")
|
||||
@RequireGlobalAdmin
|
||||
public R<Map<String, Object>> status() { return R.ok(managementService.status()); }
|
||||
|
||||
@Operation(summary = "Save managed DSH runtime configuration")
|
||||
@PutMapping("/config")
|
||||
@RequireGlobalAdmin
|
||||
public R<Map<String, Object>> saveConfig(@RequestBody Map<String, String> values) {
|
||||
return R.ok(managementService.saveConfig(values));
|
||||
}
|
||||
|
||||
@Operation(summary = "Install the server-selected DSH artifact")
|
||||
@PostMapping("/install")
|
||||
@RequireGlobalAdmin
|
||||
public R<Map<String, Object>> install() throws Exception { return R.ok(managementService.install()); }
|
||||
|
||||
@Operation(summary = "Verify DSH runtime configuration")
|
||||
@PostMapping("/verify")
|
||||
@RequireGlobalAdmin
|
||||
public R<Map<String, Object>> verify() { return R.ok(managementService.verify()); }
|
||||
|
||||
@Operation(summary = "Test starting the DSH process")
|
||||
@PostMapping("/test-connection")
|
||||
@RequireGlobalAdmin
|
||||
public R<Map<String, Object>> testConnection() { return R.ok(managementService.testConnection()); }
|
||||
|
||||
@Operation(summary = "Enable managed DSH runtime")
|
||||
@PostMapping("/enable")
|
||||
@RequireGlobalAdmin
|
||||
public R<Map<String, Object>> enable() { return R.ok(managementService.enable()); }
|
||||
|
||||
@Operation(summary = "Disable managed DSH runtime")
|
||||
@PostMapping("/disable")
|
||||
@RequireGlobalAdmin
|
||||
public R<Map<String, Object>> disable() { return R.ok(managementService.disable()); }
|
||||
}
|
||||
@ -0,0 +1,130 @@
|
||||
package vip.mate.agent.runtime.dsh.management;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.system.service.SystemSettingService;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Service
|
||||
public class DshManagementService {
|
||||
private static final String ENABLED_KEY = "dsh.enabled";
|
||||
|
||||
private final DshRuntimeConfigService configService;
|
||||
private final DshArtifactInstaller installer;
|
||||
private final SystemSettingService settings;
|
||||
|
||||
public DshManagementService(DshRuntimeConfigService configService,
|
||||
DshArtifactInstaller installer,
|
||||
SystemSettingService settings) {
|
||||
this.configService = configService;
|
||||
this.installer = installer;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
public Map<String, Object> status() {
|
||||
DshRuntimeConfiguration configuration = configService.resolve();
|
||||
boolean executableAvailable = isExecutable(configuration.executablePath());
|
||||
boolean workingDirectoryAvailable = configuration.workingDirectory() != null
|
||||
&& Files.isDirectory(Path.of(configuration.workingDirectory()));
|
||||
boolean cordisAvailable = configuration.cordisConfigPath() == null
|
||||
|| configuration.cordisConfigPath().isBlank()
|
||||
|| Files.isRegularFile(Path.of(configuration.cordisConfigPath()));
|
||||
// An empty managed key is valid: DshRuntimeService can reuse the
|
||||
// existing DeepSeek provider key. The page may still store a managed
|
||||
// key when the operator wants DSH to be independent from model rows.
|
||||
boolean enabled = settings.getBool(ENABLED_KEY, false);
|
||||
DshManagementState state;
|
||||
if (!executableAvailable) state = DshManagementState.NOT_INSTALLED;
|
||||
else if (!workingDirectoryAvailable || !cordisAvailable) state = DshManagementState.CONFIG_INVALID;
|
||||
else if (enabled) state = DshManagementState.ENABLED;
|
||||
else state = DshManagementState.READY;
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("state", state.name());
|
||||
result.put("installed", executableAvailable);
|
||||
result.put("enabled", enabled);
|
||||
result.put("config", configuration.publicStatus());
|
||||
result.put("managed", configService.managedValues());
|
||||
result.put("artifactManifestConfigured", installer.manifestConfigured());
|
||||
result.put("privateArtifactManifestConfigured", installer.privateManifestConfigured());
|
||||
result.put("checkedAt", Instant.now().toString());
|
||||
return result;
|
||||
}
|
||||
|
||||
public Map<String, Object> saveConfig(Map<String, String> values) {
|
||||
configService.save(values);
|
||||
return status();
|
||||
}
|
||||
|
||||
public Map<String, Object> install() throws Exception {
|
||||
DshArtifactManifest manifest = installer.loadManifest();
|
||||
Path executable = installer.install(manifest);
|
||||
Map<String, String> installed = new LinkedHashMap<>();
|
||||
installed.put("dsh.executable_path", executable.toString());
|
||||
Path cordis = installer.installedCordisConfig();
|
||||
if (cordis != null) installed.put("dsh.cordis_config_path", cordis.toString());
|
||||
configService.save(installed);
|
||||
return status();
|
||||
}
|
||||
|
||||
public Map<String, Object> verify() {
|
||||
Map<String, Object> result = status();
|
||||
boolean ok = "ENABLED".equals(result.get("state")) || "READY".equals(result.get("state"));
|
||||
result.put("verified", ok);
|
||||
result.put("verificationMessage", ok ? "DSH executable and configuration are available" : "DSH executable or configuration is unavailable");
|
||||
return result;
|
||||
}
|
||||
|
||||
public Map<String, Object> testConnection() {
|
||||
DshRuntimeConfiguration configuration = configService.resolve();
|
||||
if (!isExecutable(configuration.executablePath())) return Map.of("success", false, "message", "DSH executable is unavailable");
|
||||
if (configuration.cordisConfigPath() == null || configuration.cordisConfigPath().isBlank()) {
|
||||
return Map.of("success", false, "message", "DSH Cordis configuration is unavailable");
|
||||
}
|
||||
try {
|
||||
ProcessBuilder builder = new ProcessBuilder(configuration.executablePath(), configuration.cordisConfigPath())
|
||||
.directory(Path.of(configuration.workingDirectory()).toFile())
|
||||
.redirectErrorStream(true);
|
||||
builder.environment().put("DSH_CWD", configuration.workingDirectory());
|
||||
builder.environment().put("DSH_CORDIS_CONFIG", configuration.cordisConfigPath());
|
||||
if (configuration.apiKey() != null && !configuration.apiKey().isBlank()) {
|
||||
builder.environment().put("DEEPSEEK_API_KEY", configuration.apiKey());
|
||||
}
|
||||
if (configuration.baseUrl() != null && !configuration.baseUrl().isBlank()) {
|
||||
builder.environment().put("DEEPSEEK_BASE_URL", configuration.baseUrl());
|
||||
}
|
||||
Process process = builder.start();
|
||||
boolean finished = process.waitFor(5, TimeUnit.SECONDS);
|
||||
if (!finished) {
|
||||
process.destroyForcibly();
|
||||
return Map.of("success", true, "message", "DSH process started");
|
||||
}
|
||||
String output = new String(process.getInputStream().readAllBytes());
|
||||
if (process.exitValue() != 0) throw new IllegalStateException(output.isBlank() ? "DSH process exited with code " + process.exitValue() : output.trim());
|
||||
return Map.of("success", true, "message", output.trim());
|
||||
} catch (Exception error) {
|
||||
return Map.of("success", false, "message", "DSH connection test failed: " + error.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public Map<String, Object> enable() {
|
||||
Map<String, Object> current = verify();
|
||||
if (!Boolean.TRUE.equals(current.get("verified"))) throw new IllegalStateException("DSH must pass verification before enabling");
|
||||
settings.saveBool(ENABLED_KEY, true, "Enable managed DeepSeek Harness runtime");
|
||||
return status();
|
||||
}
|
||||
|
||||
public Map<String, Object> disable() {
|
||||
settings.saveBool(ENABLED_KEY, false, "Enable managed DeepSeek Harness runtime");
|
||||
return status();
|
||||
}
|
||||
|
||||
private boolean isExecutable(String path) {
|
||||
return path != null && !path.isBlank() && Files.isExecutable(Path.of(path));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
package vip.mate.agent.runtime.dsh.management;
|
||||
|
||||
/** Lifecycle states exposed by the DSH runtime management screen. */
|
||||
public enum DshManagementState {
|
||||
NOT_INSTALLED,
|
||||
INSTALLING,
|
||||
INSTALLED_UNCONFIGURED,
|
||||
CONFIG_INVALID,
|
||||
CHECKING,
|
||||
CHECK_FAILED,
|
||||
READY,
|
||||
ENABLED;
|
||||
|
||||
public boolean canEnable() {
|
||||
return this == READY;
|
||||
}
|
||||
|
||||
public boolean isOperational() {
|
||||
return this == ENABLED;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
package vip.mate.agent.runtime.dsh.management;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/** Resolves managed settings first, then application properties, then legacy environment variables. */
|
||||
public final class DshRuntimeConfigResolver {
|
||||
|
||||
private DshRuntimeConfigResolver() {
|
||||
}
|
||||
|
||||
public static DshRuntimeConfiguration resolve(
|
||||
Map<String, String> managed,
|
||||
Map<String, String> properties,
|
||||
Map<String, String> environment) {
|
||||
return new DshRuntimeConfiguration(
|
||||
firstNonBlank(managed, properties, environment,
|
||||
"dsh.executable_path", "mateclaw.agent.runtime.dsh.command", "DSH_JSONRPC_AGENT"),
|
||||
firstNonBlank(managed, properties, environment,
|
||||
"dsh.cordis_config_path", "mateclaw.agent.runtime.dsh.cordis-config", "DSH_CORDIS_CONFIG"),
|
||||
firstNonBlank(managed, properties, environment,
|
||||
"dsh.working_directory", "mateclaw.agent.runtime.dsh.working-directory", "DSH_CWD"),
|
||||
firstNonBlank(managed, properties, environment,
|
||||
"dsh.base_url", "mateclaw.agent.runtime.dsh.base-url", "DEEPSEEK_BASE_URL"),
|
||||
firstNonBlank(managed, properties, environment,
|
||||
"dsh.model_name", "mateclaw.agent.runtime.dsh.model-name", "DEEPSEEK_MODEL"),
|
||||
firstNonBlank(managed, properties, environment,
|
||||
"dsh.api_key", "mateclaw.agent.runtime.dsh.api-key", "DEEPSEEK_API_KEY"));
|
||||
}
|
||||
|
||||
private static String firstNonBlank(
|
||||
Map<String, String> managed,
|
||||
Map<String, String> properties,
|
||||
Map<String, String> environment,
|
||||
String managedKey,
|
||||
String propertyKey,
|
||||
String environmentKey) {
|
||||
String value = value(managed, managedKey);
|
||||
if (value != null) {
|
||||
return value;
|
||||
}
|
||||
value = value(properties, propertyKey);
|
||||
if (value != null) {
|
||||
return value;
|
||||
}
|
||||
return value(environment, environmentKey);
|
||||
}
|
||||
|
||||
private static String value(Map<String, String> values, String key) {
|
||||
if (values == null) {
|
||||
return null;
|
||||
}
|
||||
String value = values.get(key);
|
||||
return value == null || value.isBlank() ? null : value.trim();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,112 @@
|
||||
package vip.mate.agent.runtime.dsh.management;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.system.service.SystemSettingService;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/** Reads the current DSH configuration without requiring a backend restart. */
|
||||
@Service
|
||||
public class DshRuntimeConfigService {
|
||||
private static final String[] MANAGED_KEYS = {
|
||||
"dsh.executable_path", "dsh.cordis_config_path", "dsh.working_directory",
|
||||
"dsh.base_url", "dsh.model_name", SystemSettingService.DSH_API_KEY_KEY
|
||||
};
|
||||
|
||||
private final SystemSettingService settings;
|
||||
private final Map<String, String> properties;
|
||||
|
||||
public DshRuntimeConfigService(
|
||||
SystemSettingService settings,
|
||||
@Value("${mateclaw.agent.runtime.dsh.command:}") String command,
|
||||
@Value("${mateclaw.agent.runtime.dsh.cordis-config:}") String cordisConfig,
|
||||
@Value("${mateclaw.agent.runtime.dsh.working-directory:}") String workingDirectory,
|
||||
@Value("${mateclaw.agent.runtime.dsh.base-url:}") String baseUrl,
|
||||
@Value("${mateclaw.agent.runtime.dsh.model-name:}") String modelName,
|
||||
@Value("${mateclaw.agent.runtime.dsh.api-key:}") String apiKey) {
|
||||
this.settings = settings;
|
||||
this.properties = Map.of(
|
||||
"mateclaw.agent.runtime.dsh.command", command,
|
||||
"mateclaw.agent.runtime.dsh.cordis-config", cordisConfig,
|
||||
"mateclaw.agent.runtime.dsh.working-directory", workingDirectory,
|
||||
"mateclaw.agent.runtime.dsh.base-url", baseUrl,
|
||||
"mateclaw.agent.runtime.dsh.model-name", modelName,
|
||||
"mateclaw.agent.runtime.dsh.api-key", apiKey);
|
||||
}
|
||||
|
||||
public DshRuntimeConfiguration resolve() {
|
||||
Map<String, String> managed = new LinkedHashMap<>();
|
||||
for (String key : MANAGED_KEYS) {
|
||||
String defaultValue = key.equals("dsh.working_directory") ? "" : null;
|
||||
managed.put(key, settings.getString(key, defaultValue));
|
||||
}
|
||||
DshRuntimeConfiguration resolved = DshRuntimeConfigResolver.resolve(managed, properties, System.getenv());
|
||||
String workingDirectory = resolved.workingDirectory();
|
||||
if (workingDirectory == null || workingDirectory.isBlank()) workingDirectory = System.getProperty("user.dir");
|
||||
String cordisConfig = normalizeCordisConfig(resolved.cordisConfigPath());
|
||||
if (cordisConfig.isBlank()) cordisConfig = discoverCordisConfig(resolved.executablePath());
|
||||
return new DshRuntimeConfiguration(resolved.executablePath(), cordisConfig, workingDirectory,
|
||||
resolved.baseUrl(), resolved.modelName(), resolved.apiKey());
|
||||
}
|
||||
|
||||
private String discoverCordisConfig(String executable) {
|
||||
if (executable == null || executable.isBlank()) return "";
|
||||
Path binary = Path.of(executable.split("\\s+")[0]).toAbsolutePath().normalize();
|
||||
Path packageRoot = binary.getParent();
|
||||
if (packageRoot == null) return "";
|
||||
Path[] candidates = {
|
||||
packageRoot.resolve("runtime/cordis.yml"),
|
||||
packageRoot.resolve("../runtime/cordis.yml").normalize(),
|
||||
packageRoot.resolve("../examples/jsonrpc-agent/cordis.yml").normalize(),
|
||||
packageRoot.resolve("../../examples/jsonrpc-agent/cordis.yml").normalize()
|
||||
};
|
||||
for (Path candidate : candidates) if (Files.isRegularFile(candidate)) return candidate.toString();
|
||||
return "";
|
||||
}
|
||||
|
||||
private String normalizeCordisConfig(String configured) {
|
||||
if (configured == null || configured.isBlank()) return "";
|
||||
Path path = Path.of(configured).toAbsolutePath().normalize();
|
||||
if (Files.isRegularFile(path)) return path.toString();
|
||||
Path packageDirectory = Files.isDirectory(path) ? path : path.getParent();
|
||||
if (packageDirectory == null) return path.toString();
|
||||
Path packagedConfig = packageDirectory.resolve("runtime/cordis.yml");
|
||||
return Files.isRegularFile(packagedConfig) ? packagedConfig.toString() : path.toString();
|
||||
}
|
||||
|
||||
public Map<String, String> managedValues() {
|
||||
Map<String, String> values = new LinkedHashMap<>();
|
||||
for (String key : MANAGED_KEYS) {
|
||||
String value = settings.getString(key, "");
|
||||
if (SystemSettingService.DSH_API_KEY_KEY.equals(key)) {
|
||||
values.put(key, settings.maskSecret(value));
|
||||
} else {
|
||||
values.put(key, value == null ? "" : value);
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
public void save(Map<String, String> values) {
|
||||
if (values == null) return;
|
||||
save(values, "dsh.executable_path");
|
||||
save(values, "dsh.cordis_config_path");
|
||||
save(values, "dsh.working_directory");
|
||||
save(values, "dsh.base_url");
|
||||
save(values, "dsh.model_name");
|
||||
String apiKey = values.get(SystemSettingService.DSH_API_KEY_KEY);
|
||||
if (apiKey != null && !apiKey.isBlank() && !apiKey.startsWith("****")) {
|
||||
settings.saveString(SystemSettingService.DSH_API_KEY_KEY, apiKey.trim(), "DeepSeek API key for DSH");
|
||||
}
|
||||
}
|
||||
|
||||
private void save(Map<String, String> values, String key) {
|
||||
if (values.containsKey(key)) {
|
||||
settings.saveString(key, values.get(key), "Managed DeepSeek Harness runtime setting");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
package vip.mate.agent.runtime.dsh.management;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/** Resolved DSH settings. The API key is deliberately omitted from public projections. */
|
||||
public record DshRuntimeConfiguration(
|
||||
String executablePath,
|
||||
String cordisConfigPath,
|
||||
String workingDirectory,
|
||||
String baseUrl,
|
||||
String modelName,
|
||||
String apiKey) {
|
||||
|
||||
public Map<String, Object> publicStatus() {
|
||||
Map<String, Object> status = new LinkedHashMap<>();
|
||||
status.put("executablePath", executablePath);
|
||||
status.put("cordisConfigPath", cordisConfigPath);
|
||||
status.put("workingDirectory", workingDirectory);
|
||||
status.put("baseUrl", baseUrl);
|
||||
status.put("modelName", modelName);
|
||||
status.put("apiKeyConfigured", apiKey != null && !apiKey.isBlank());
|
||||
return status;
|
||||
}
|
||||
}
|
||||
@ -544,17 +544,41 @@ public class ChatStreamTracker {
|
||||
*/
|
||||
public void setDisposable(String conversationId, Disposable disposable) {
|
||||
RunState state = runs.get(conversationId);
|
||||
if (state != null) {
|
||||
if (state == null || disposable == null) return;
|
||||
boolean disposeImmediately;
|
||||
synchronized (state.lock) {
|
||||
if (!isCurrent(state)) return;
|
||||
state.disposable = disposable;
|
||||
// Stop can win before the asynchronous SSE setup has subscribed
|
||||
// and registered its Disposable. Do not let that late subscription
|
||||
// escape the cancellation request.
|
||||
disposeImmediately = state.done || state.stopRequested.get();
|
||||
}
|
||||
if (disposeImmediately) {
|
||||
disposeSafely(conversationId, disposable);
|
||||
}
|
||||
}
|
||||
|
||||
public void setDisposable(RunHandle handle, Disposable disposable) {
|
||||
if (handle == null) return;
|
||||
if (handle == null || disposable == null) return;
|
||||
RunState state = handle.state;
|
||||
boolean disposeImmediately;
|
||||
synchronized (state.lock) {
|
||||
if (!isCurrent(state)) return;
|
||||
state.disposable = disposable;
|
||||
disposeImmediately = state.done || state.stopRequested.get();
|
||||
}
|
||||
if (disposeImmediately) {
|
||||
disposeSafely(state.conversationId, disposable);
|
||||
}
|
||||
}
|
||||
|
||||
private void disposeSafely(String conversationId, Disposable disposable) {
|
||||
try {
|
||||
disposable.dispose();
|
||||
} catch (Exception e) {
|
||||
log.warn("Late stream disposable cancellation failed for {}: {}",
|
||||
conversationId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -99,6 +99,9 @@ public class SystemSettingService {
|
||||
*/
|
||||
private static final String WORKSPACE_STORAGE_ROOT_KEY = "workspace.storage_root";
|
||||
|
||||
/** Managed DeepSeek Harness runtime configuration. */
|
||||
public static final String DSH_API_KEY_KEY = "dsh.api_key";
|
||||
|
||||
private static final String ZHIPU_API_KEY_KEY = "zhipuApiKey";
|
||||
private static final String ZHIPU_BASE_URL_KEY = "zhipuBaseUrl";
|
||||
private static final String FAL_API_KEY_KEY = "falApiKey";
|
||||
@ -116,7 +119,7 @@ public class SystemSettingService {
|
||||
private static final Set<String> SENSITIVE_KEYS = Set.of(
|
||||
SERPER_API_KEY_KEY, TAVILY_API_KEY_KEY, WEIXINOA_APP_SECRET_KEY,
|
||||
ZHIPU_API_KEY_KEY, FAL_API_KEY_KEY, KLING_ACCESS_KEY_KEY, KLING_SECRET_KEY_KEY,
|
||||
RUNWAY_API_KEY_KEY, MINIMAX_API_KEY_KEY);
|
||||
RUNWAY_API_KEY_KEY, MINIMAX_API_KEY_KEY, DSH_API_KEY_KEY);
|
||||
|
||||
private final SystemSettingMapper systemSettingMapper;
|
||||
private final SearchProviderRegistry searchProviderRegistry;
|
||||
@ -656,6 +659,11 @@ public class SystemSettingService {
|
||||
saveValue(key, value, description);
|
||||
}
|
||||
|
||||
/** Return a masked representation suitable for an admin status response. */
|
||||
public String maskSecret(String value) {
|
||||
return maskApiKey(value);
|
||||
}
|
||||
|
||||
private String getValue(String key, String defaultValue) {
|
||||
SystemSettingEntity entity = systemSettingMapper.selectOne(new LambdaQueryWrapper<SystemSettingEntity>()
|
||||
.eq(SystemSettingEntity::getSettingKey, key)
|
||||
|
||||
@ -135,9 +135,16 @@ mateclaw:
|
||||
# outside the Spring classpath and can be supplied by environment variables.
|
||||
agent:
|
||||
runtime:
|
||||
dsh:
|
||||
command: ${DSH_JSONRPC_AGENT:}
|
||||
cordis-config: ${DSH_CORDIS_CONFIG:}
|
||||
dsh:
|
||||
command: ${DSH_JSONRPC_AGENT:}
|
||||
cordis-config: ${DSH_CORDIS_CONFIG:}
|
||||
working-directory: ${DSH_CWD:}
|
||||
base-url: ${DEEPSEEK_BASE_URL:}
|
||||
model-name: ${DEEPSEEK_MODEL:}
|
||||
api-key: ${DEEPSEEK_API_KEY:}
|
||||
manifest-url: ${DSH_MANIFEST_URL:}
|
||||
github-release-url: ${DSH_GITHUB_RELEASE_URL:https://api.github.com/repos/deepseek-ai/deepseek-harness/releases/latest}
|
||||
install-root: ${DSH_INSTALL_ROOT:${user.home}/.mateclaw/runtimes/deepseek-harness}
|
||||
server:
|
||||
# Public base URL used to build absolute download links for tool-generated
|
||||
# files (e.g. https://mateclaw.example.com). Leave empty to fall back to the
|
||||
|
||||
@ -0,0 +1,114 @@
|
||||
package vip.mate.agent.runtime.dsh;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import reactor.core.Disposable;
|
||||
import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.agent.runtime.contract.RuntimeEvent;
|
||||
import vip.mate.agent.runtime.contract.RuntimeEventType;
|
||||
import vip.mate.agent.runtime.contract.RuntimeSession;
|
||||
import vip.mate.agent.runtime.dsh.management.DshRuntimeConfigService;
|
||||
import vip.mate.agent.runtime.dsh.management.DshRuntimeConfiguration;
|
||||
import vip.mate.llm.service.ModelConfigService;
|
||||
import vip.mate.llm.service.ModelProviderService;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.time.Duration;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTimeout;
|
||||
|
||||
class DshRuntimeServiceTest {
|
||||
|
||||
@Test
|
||||
void sessionWorkingDirectoryWinsOverGlobalRuntimeDirectory() {
|
||||
RuntimeSession session = new RuntimeSession(
|
||||
"session-1", "conversation-1", 1L, 2L, "model",
|
||||
Path.of("/workspace/agent"), Map.of());
|
||||
DshRuntimeConfiguration configuration = new DshRuntimeConfiguration(
|
||||
"/bin/dsh", "", "/workspace/global", "", "", "");
|
||||
|
||||
assertEquals(Path.of("/workspace/agent"),
|
||||
DshRuntimeService.resolveWorkingDirectory(session, configuration));
|
||||
}
|
||||
|
||||
@Test
|
||||
void usageChunkBecomesContextUsageEvent() throws Exception {
|
||||
DshRuntimeService service = service();
|
||||
RuntimeEvent event = service.mapEvent("session-1", 7,
|
||||
new ObjectMapper().readTree("""
|
||||
{
|
||||
"type":"assistant/chunk",
|
||||
"data":{"chunk":{"type":"usage","usage":{"inputTokens":123,"outputTokens":45}}}
|
||||
}
|
||||
"""));
|
||||
|
||||
assertEquals(RuntimeEventType.CONTEXT_USAGE, event.type());
|
||||
assertEquals(123L, ((Number) event.data().get("promptTokens")).longValue());
|
||||
assertEquals(45L, ((Number) event.data().get("completionTokens")).longValue());
|
||||
assertEquals(123L, ((Number) event.data().get("inputTokens")).longValue());
|
||||
assertEquals(45L, ((Number) event.data().get("outputTokens")).longValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelProcessDestroysLiveProcess() {
|
||||
Process process = Mockito.mock(Process.class);
|
||||
Mockito.when(process.isAlive()).thenReturn(true);
|
||||
|
||||
DshRuntimeService.cancelProcess(process);
|
||||
|
||||
Mockito.verify(process).destroy();
|
||||
Mockito.verify(process).destroyForcibly();
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelProcessStopsChildHoldingParentPipes() throws Exception {
|
||||
Process process = new ProcessBuilder("sh", "-c", "sleep 30").start();
|
||||
try {
|
||||
DshRuntimeService.cancelProcess(process);
|
||||
assertTrue(process.waitFor(2, TimeUnit.SECONDS), "parent process should stop promptly");
|
||||
assertTrue(process.exitValue() != 0 || !process.isAlive());
|
||||
} finally {
|
||||
if (process.isAlive()) process.destroyForcibly();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void streamSubscriptionReturnsBeforeSynchronousDshReadLoopFinishes() {
|
||||
DshRuntimeService service = service("/bin/sh -c \"sleep 5\"");
|
||||
AgentEntity agent = new AgentEntity();
|
||||
agent.setId(1L);
|
||||
agent.setWorkspaceId(2L);
|
||||
agent.setModelName("model");
|
||||
|
||||
Disposable subscription = assertTimeout(Duration.ofSeconds(2), () ->
|
||||
service.stream(agent, "hello", "conversation", "model").subscribe());
|
||||
assertFalse(subscription.isDisposed());
|
||||
subscription.dispose();
|
||||
}
|
||||
|
||||
@Test
|
||||
void commandLineKeepsQuotedExecutablePathTogether() {
|
||||
assertEquals(List.of("/opt/Deep Seek/dsh-jsonrpc-agent", "--stdio"),
|
||||
DshRuntimeService.commandLine("\"/opt/Deep Seek/dsh-jsonrpc-agent\" --stdio"));
|
||||
}
|
||||
|
||||
private static DshRuntimeService service() {
|
||||
return service("/bin/dsh");
|
||||
}
|
||||
|
||||
private static DshRuntimeService service(String executable) {
|
||||
DshRuntimeConfigService config = Mockito.mock(DshRuntimeConfigService.class);
|
||||
Mockito.when(config.resolve()).thenReturn(new DshRuntimeConfiguration(
|
||||
executable, "", "/tmp", "", "model", ""));
|
||||
return new DshRuntimeService(new ObjectMapper(),
|
||||
Mockito.mock(ModelConfigService.class),
|
||||
Mockito.mock(ModelProviderService.class), config);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
package vip.mate.agent.runtime.dsh.management;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class DshManagementStateTest {
|
||||
|
||||
@Test
|
||||
void runtimeCannotBeEnabledBeforeVerificationIsReady() {
|
||||
assertFalse(DshManagementState.CONFIG_INVALID.canEnable());
|
||||
assertFalse(DshManagementState.CHECK_FAILED.canEnable());
|
||||
assertTrue(DshManagementState.READY.canEnable());
|
||||
}
|
||||
|
||||
@Test
|
||||
void installationAndVerificationStatesAreNotConfusedWithEnabled() {
|
||||
assertFalse(DshManagementState.NOT_INSTALLED.isOperational());
|
||||
assertFalse(DshManagementState.INSTALLED_UNCONFIGURED.isOperational());
|
||||
assertFalse(DshManagementState.READY.isOperational());
|
||||
assertTrue(DshManagementState.ENABLED.isOperational());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,70 @@
|
||||
package vip.mate.agent.runtime.dsh.management;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
class DshRuntimeConfigResolverTest {
|
||||
|
||||
@Test
|
||||
void databaseValuesOverrideEnvironmentFallback() {
|
||||
DshRuntimeConfiguration resolved = DshRuntimeConfigResolver.resolve(
|
||||
Map.of(
|
||||
"dsh.executable_path", "/managed/dsh-agent",
|
||||
"dsh.cordis_config_path", "/managed/cordis.yml",
|
||||
"dsh.working_directory", "/managed/workspace",
|
||||
"dsh.base_url", "https://managed.example.com",
|
||||
"dsh.model_name", "managed-model",
|
||||
"dsh.api_key", "managed-secret"),
|
||||
Map.of(
|
||||
"mateclaw.agent.runtime.dsh.command", "/legacy/dsh-agent",
|
||||
"mateclaw.agent.runtime.dsh.cordis-config", "/legacy/cordis.yml"),
|
||||
Map.of(
|
||||
"DSH_JSONRPC_AGENT", "/env/dsh-agent",
|
||||
"DSH_CORDIS_CONFIG", "/env/cordis.yml",
|
||||
"DSH_CWD", "/env/workspace"));
|
||||
|
||||
assertEquals("/managed/dsh-agent", resolved.executablePath());
|
||||
assertEquals("/managed/cordis.yml", resolved.cordisConfigPath());
|
||||
assertEquals("/managed/workspace", resolved.workingDirectory());
|
||||
assertEquals("https://managed.example.com", resolved.baseUrl());
|
||||
assertEquals("managed-model", resolved.modelName());
|
||||
assertEquals("managed-secret", resolved.apiKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankDatabaseValuesFallBackToPropertiesThenEnvironment() {
|
||||
DshRuntimeConfiguration resolved = DshRuntimeConfigResolver.resolve(
|
||||
Map.of(
|
||||
"dsh.executable_path", "",
|
||||
"dsh.cordis_config_path", "",
|
||||
"dsh.working_directory", ""),
|
||||
Map.of(
|
||||
"mateclaw.agent.runtime.dsh.command", "/properties/dsh-agent",
|
||||
"mateclaw.agent.runtime.dsh.cordis-config", "/properties/cordis.yml"),
|
||||
Map.of(
|
||||
"DSH_JSONRPC_AGENT", "/env/dsh-agent",
|
||||
"DSH_CORDIS_CONFIG", "/env/cordis.yml",
|
||||
"DSH_CWD", "/env/workspace"));
|
||||
|
||||
assertEquals("/properties/dsh-agent", resolved.executablePath());
|
||||
assertEquals("/properties/cordis.yml", resolved.cordisConfigPath());
|
||||
assertEquals("/env/workspace", resolved.workingDirectory());
|
||||
}
|
||||
|
||||
@Test
|
||||
void apiKeyIsExcludedFromPublicStatusProjection() {
|
||||
DshRuntimeConfiguration resolved = DshRuntimeConfigResolver.resolve(
|
||||
Map.of("dsh.api_key", "super-secret"), Map.of(), Map.of());
|
||||
|
||||
Map<String, Object> status = resolved.publicStatus();
|
||||
|
||||
assertEquals(true, status.get("apiKeyConfigured"));
|
||||
assertFalse(status.containsKey("apiKey"));
|
||||
assertNull(status.get("apiKey"));
|
||||
}
|
||||
}
|
||||
@ -376,6 +376,22 @@ class ChatStreamTrackerOrphanPolicyTest {
|
||||
assertFalse(staleLateDisposable.isDisposed());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("A disposable registered after Stop is requested is cancelled immediately")
|
||||
void lateDisposableIsCancelledWhenStopWonTheRace() {
|
||||
ChatStreamTracker tracker = newTracker();
|
||||
String cid = "late-stop-disposable";
|
||||
ChatStreamTracker.RunHandle handle = tracker.register(cid);
|
||||
tracker.incrementFlux(cid);
|
||||
|
||||
assertTrue(tracker.requestStop(cid));
|
||||
|
||||
RecordingDisposable lateDisposable = new RecordingDisposable();
|
||||
tracker.setDisposable(handle, lateDisposable);
|
||||
|
||||
assertTrue(lateDisposable.isDisposed());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("A throwing disposable cannot leave an evicting tombstone mapped")
|
||||
void throwingDisposableStillRemovesClaimedState() {
|
||||
|
||||
@ -754,6 +754,17 @@ export const settingsApi = {
|
||||
getSearchProviders: () => http.get('/settings/search-providers'),
|
||||
}
|
||||
|
||||
// ==================== DeepSeek Harness runtime ====================
|
||||
export const dshApi = {
|
||||
status: () => http.get('/admin/dsh/status'),
|
||||
saveConfig: (data: Record<string, string>) => http.put('/admin/dsh/config', data),
|
||||
install: () => http.post('/admin/dsh/install'),
|
||||
verify: () => http.post('/admin/dsh/verify'),
|
||||
testConnection: () => http.post('/admin/dsh/test-connection'),
|
||||
enable: () => http.post('/admin/dsh/enable'),
|
||||
disable: () => http.post('/admin/dsh/disable'),
|
||||
}
|
||||
|
||||
// ==================== Global outbound proxy ====================
|
||||
export const proxyApi = {
|
||||
get: () => http.get('/settings/proxy'),
|
||||
|
||||
@ -715,6 +715,7 @@ export default {
|
||||
core: 'Core',
|
||||
connect: 'Connect',
|
||||
system: 'System',
|
||||
dsh: 'DeepSeek Harness',
|
||||
datasources: 'Datasources',
|
||||
mcpServers: 'MCP Connections',
|
||||
mcpConnections: 'MCP Connections',
|
||||
|
||||
@ -706,6 +706,7 @@ export default {
|
||||
core: '核心',
|
||||
connect: '连接',
|
||||
system: '系统',
|
||||
dsh: 'DeepSeek Harness',
|
||||
agent: '智能体',
|
||||
workspace: '工作区',
|
||||
agentContext: '智能体上下文',
|
||||
|
||||
@ -149,6 +149,12 @@ const router = createRouter({
|
||||
component: () => import('@/views/Settings/System/index.vue'),
|
||||
meta: { title: 'Settings - System', requiredCapability: 'manage:settings' },
|
||||
},
|
||||
{
|
||||
path: 'dsh',
|
||||
name: 'SettingsDsh',
|
||||
component: () => import('@/views/Settings/Dsh/index.vue'),
|
||||
meta: { title: 'Settings - DeepSeek Harness', requiredCapability: 'manage:settings' },
|
||||
},
|
||||
{
|
||||
path: 'image',
|
||||
name: 'SettingsImage',
|
||||
|
||||
113
mateclaw-ui/src/views/Settings/Dsh/index.vue
Normal file
113
mateclaw-ui/src/views/Settings/Dsh/index.vue
Normal file
@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="settings-section dsh-page">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<div class="mc-page-kicker">运行时管理</div>
|
||||
<h2 class="section-title">DeepSeek Harness</h2>
|
||||
<p class="section-desc">在页面完成安装、配置、检测和启用,不再依赖 IDEA 的环境变量。</p>
|
||||
</div>
|
||||
<span class="state-pill" :class="`state-${state.toLowerCase()}`">{{ stateLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div class="dsh-steps mc-surface-card">
|
||||
<div v-for="step in steps" :key="step.id" class="dsh-step" :class="{ done: step.done, active: step.active }">
|
||||
<span class="step-index">{{ step.done ? '✓' : step.id }}</span>
|
||||
<div><strong>{{ step.title }}</strong><small>{{ step.description }}</small></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="settings-card error-card">{{ error }}</div>
|
||||
<div v-if="loading" class="settings-card loading-card">正在读取 DSH 运行时状态...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="settings-card">
|
||||
<div class="card-heading"><div><h3>运行时配置</h3><p>托管配置优先于 application.yml 和旧环境变量。</p></div><span v-if="status.config?.apiKeyConfigured" class="configured-badge">API Key 已配置</span></div>
|
||||
<div class="form-grid">
|
||||
<label><span>可执行文件</span><input v-model="form.executable_path" placeholder="/path/to/dsh-jsonrpc-agent" /></label>
|
||||
<label><span>Cordis 配置</span><input v-model="form.cordis_config_path" placeholder="/path/to/cordis.yml" /></label>
|
||||
<label><span>工作目录</span><input v-model="form.working_directory" placeholder="/path/to/workspace" /></label>
|
||||
<label><span>DeepSeek Base URL</span><input v-model="form.base_url" placeholder="https://api.deepseek.com" /></label>
|
||||
<label><span>模型</span><input v-model="form.model_name" placeholder="deepseek-v4-flash" /></label>
|
||||
<label><span>API Key</span><input v-model="form.api_key" type="password" autocomplete="new-password" placeholder="留空表示保持当前值" /></label>
|
||||
</div>
|
||||
<div class="actions"><button class="btn-primary" :disabled="busy" @click="save">保存配置</button><button class="btn-secondary" :disabled="busy" @click="verify">验证配置</button></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card install-card">
|
||||
<div class="card-heading"><div><h3>安装与连接</h3><p>安装包由服务端私有制品清单提供,前端不能注入任意下载地址。</p></div></div>
|
||||
<div class="actions">
|
||||
<button class="btn-secondary" :disabled="busy || !status.artifactManifestConfigured" @click="install">安装 / 更新 DSH</button>
|
||||
<button class="btn-secondary" :disabled="busy || !status.installed" @click="testConnection">测试进程</button>
|
||||
<button v-if="status.enabled" class="btn-danger" :disabled="busy" @click="disable">停用</button>
|
||||
<button v-else class="btn-primary" :disabled="busy || !canEnable" @click="enable">启用 DSH</button>
|
||||
</div>
|
||||
<p v-if="!status.artifactManifestConfigured" class="muted-note">当前未配置私有制品清单。可以继续使用已有安装路径或旧环境变量;要启用页面安装,请设置 DSH_MANIFEST_URL。</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-card diagnostics-card">
|
||||
<div class="card-heading"><div><h3>检测结果</h3><p>敏感信息只显示是否已配置。</p></div></div>
|
||||
<dl><div><dt>状态</dt><dd>{{ stateLabel }}</dd></div><div><dt>可执行文件</dt><dd>{{ status.config?.executablePath || '未配置' }}</dd></div><div><dt>工作目录</dt><dd>{{ status.config?.workingDirectory || '未配置' }}</dd></div><div><dt>API Key</dt><dd>{{ status.config?.apiKeyConfigured ? '已配置' : '未配置' }}</dd></div></dl>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { dshApi } from '@/api'
|
||||
import { mcToast } from '@/composables/useMcToast'
|
||||
|
||||
const loading = ref(true)
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
const status = reactive<any>({ state: 'NOT_INSTALLED', installed: false, enabled: false, config: {}, artifactManifestConfigured: false })
|
||||
const form = reactive<Record<string, string>>({ executable_path: '', cordis_config_path: '', working_directory: '', base_url: '', model_name: '', api_key: '' })
|
||||
const state = computed(() => String(status.state || 'NOT_INSTALLED'))
|
||||
const stateLabel = computed(() => ({ NOT_INSTALLED: '未安装', INSTALLING: '安装中', INSTALLED_UNCONFIGURED: '已安装待验证', CONFIG_INVALID: '配置不完整', CHECKING: '检测中', CHECK_FAILED: '检测失败', READY: '已就绪', ENABLED: '已启用' } as Record<string, string>)[state.value] || state.value)
|
||||
const canEnable = computed(() => status.installed && status.config?.workingDirectory && state.value !== 'CONFIG_INVALID')
|
||||
const steps = computed(() => [
|
||||
{ id: 1, title: '安装', description: status.installed ? '运行时已发现' : '从私有制品源安装', done: status.installed, active: !status.installed },
|
||||
{ id: 2, title: '配置', description: status.config?.workingDirectory ? '连接参数已解析' : '填写运行目录,可复用已有 Provider Key', done: !!status.config?.workingDirectory && state.value !== 'CONFIG_INVALID', active: status.installed && state.value === 'CONFIG_INVALID' },
|
||||
{ id: 3, title: '验证', description: canEnable.value ? '可进行进程测试' : '先完成前两步', done: canEnable.value, active: false },
|
||||
{ id: 4, title: '启用', description: status.enabled ? 'DSH 已接管运行时' : '启用后新任务使用托管配置', done: status.enabled, active: canEnable.value && !status.enabled },
|
||||
])
|
||||
|
||||
function applyResponse(response: any) {
|
||||
const data = response?.data ?? response
|
||||
Object.assign(status, data)
|
||||
const managed = data?.managed || {}
|
||||
for (const key of Object.keys(form)) form[key] = managed[key] || ''
|
||||
if (form.api_key.startsWith('****')) form.api_key = ''
|
||||
}
|
||||
|
||||
async function load() { loading.value = true; error.value = ''; try { applyResponse(await dshApi.status()) } catch (e: any) { error.value = e?.message || '读取 DSH 状态失败' } finally { loading.value = false } }
|
||||
async function run(action: () => Promise<any>, message: string) { busy.value = true; error.value = ''; try { applyResponse(await action()); mcToast.success(message) } catch (e: any) { error.value = e?.message || '操作失败'; mcToast.error(error.value) } finally { busy.value = false } }
|
||||
function save() { return run(() => dshApi.saveConfig(form), 'DSH 配置已保存') }
|
||||
function verify() { return run(dshApi.verify, 'DSH 配置验证完成') }
|
||||
function install() { return run(dshApi.install, 'DSH 安装完成') }
|
||||
function testConnection() { return run(async () => { const response: any = await dshApi.testConnection(); const data = response?.data ?? response; if (data?.success === false) throw new Error(data.message || 'DSH 进程测试失败'); return response }, 'DSH 进程测试通过') }
|
||||
function enable() { return run(dshApi.enable, 'DSH 已启用') }
|
||||
function disable() { return run(dshApi.disable, 'DSH 已停用') }
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dsh-page { width: 100%; }
|
||||
.section-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; margin-bottom: 18px; }
|
||||
.section-title { margin: 3px 0 6px; font-size: 24px; color: var(--mc-text-primary); }
|
||||
.section-desc, .card-heading p { margin: 0; color: var(--mc-text-secondary); font-size: 13px; line-height: 1.55; }
|
||||
.state-pill, .configured-badge { display: inline-flex; border: 1px solid var(--mc-border); border-radius: 999px; padding: 6px 10px; color: var(--mc-text-secondary); font-size: 12px; white-space: nowrap; background: rgba(255,255,255,.35); }
|
||||
.state-enabled, .configured-badge { color: var(--mc-success, #287a52); border-color: rgba(40,122,82,.25); }
|
||||
.dsh-steps { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; padding: 14px; margin-bottom: 14px; }
|
||||
.dsh-step { display: flex; align-items: center; gap: 9px; padding: 10px; color: var(--mc-text-tertiary); border-radius: 10px; }
|
||||
.dsh-step.active { background: rgba(255,255,255,.42); color: var(--mc-text-primary); }
|
||||
.dsh-step.done { color: var(--mc-success, #287a52); }
|
||||
.step-index { display: grid; place-items: center; width: 24px; height: 24px; border: 1px solid currentColor; border-radius: 50%; font-size: 11px; flex: 0 0 auto; }
|
||||
.dsh-step strong, .dsh-step small { display: block; }.dsh-step strong { font-size: 13px; }.dsh-step small { margin-top: 2px; font-size: 11px; opacity: .8; }
|
||||
.settings-card { padding: 18px; margin-bottom: 14px; border: 1px solid var(--mc-border); border-radius: 14px; background: rgba(255,255,255,.25); box-shadow: 0 8px 24px rgba(124,63,30,.04); }
|
||||
.card-heading { display: flex; justify-content: space-between; gap: 12px; align-items: flex-start; margin-bottom: 16px; }.card-heading h3 { margin: 0 0 4px; font-size: 16px; color: var(--mc-text-primary); }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }.form-grid label { display: flex; flex-direction: column; gap: 6px; min-width: 0; }.form-grid label span { color: var(--mc-text-secondary); font-size: 12px; }.form-grid input { width: 100%; min-height: 36px; padding: 8px 10px; border: 1px solid var(--mc-border); border-radius: 9px; background: rgba(255,255,255,.42); color: var(--mc-text-primary); outline: none; }.form-grid input:focus { border-color: var(--mc-primary); }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }.btn-primary, .btn-secondary, .btn-danger { border: 1px solid var(--mc-border); border-radius: 9px; padding: 8px 13px; font-size: 13px; cursor: pointer; }.btn-primary { color: #fff; background: var(--mc-primary); border-color: var(--mc-primary); }.btn-secondary { color: var(--mc-text-primary); background: rgba(255,255,255,.45); }.btn-danger { color: #a33b32; background: rgba(255,235,230,.65); border-color: rgba(163,59,50,.25); }.btn-primary:disabled, .btn-secondary:disabled, .btn-danger:disabled { opacity: .5; cursor: not-allowed; }.error-card { color: #a33b32; background: rgba(255,235,230,.62); }.loading-card { color: var(--mc-text-secondary); }.muted-note { margin: 12px 0 0; color: var(--mc-text-tertiary); font-size: 12px; line-height: 1.5; }
|
||||
dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 0; }dt { color: var(--mc-text-tertiary); font-size: 12px; }dd { margin: 3px 0 0; color: var(--mc-text-primary); font-size: 13px; word-break: break-all; }
|
||||
@media (max-width: 760px) { .dsh-steps, .form-grid, dl { grid-template-columns: 1fr; }.section-header { flex-direction: column; } }
|
||||
</style>
|
||||
@ -97,6 +97,12 @@ const sections = computed(() => [
|
||||
label: t('settings.sections.system'),
|
||||
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51h.09a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9c0 .66.26 1.3.73 1.77.47.47 1.11.73 1.77.73H21a2 2 0 1 1 0 4h-.09c-.66 0-1.3.26-1.77.73-.47.47-.73 1.11-.73 1.77z"/></svg>',
|
||||
},
|
||||
{
|
||||
id: 'dsh',
|
||||
path: '/settings/dsh',
|
||||
label: t('settings.sections.dsh', 'DeepSeek Harness'),
|
||||
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v18M3 12h18"/><circle cx="12" cy="12" r="8"/></svg>',
|
||||
},
|
||||
{
|
||||
id: 'image',
|
||||
path: '/settings/image',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user