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:
matevip 2026-08-19 04:30:06 -04:00
parent 5660c72e1a
commit a8a0b75bfa
22 changed files with 1201 additions and 57 deletions

View File

@ -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;

View File

@ -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) { }
}
}

View File

@ -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) {
}

View File

@ -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()); }
}

View File

@ -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));
}
}

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

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

View File

@ -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;
}
}

View File

@ -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());
}
}

View File

@ -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)

View File

@ -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

View File

@ -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);
}
}

View File

@ -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());
}
}

View File

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

View File

@ -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() {

View File

@ -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'),

View File

@ -715,6 +715,7 @@ export default {
core: 'Core',
connect: 'Connect',
system: 'System',
dsh: 'DeepSeek Harness',
datasources: 'Datasources',
mcpServers: 'MCP Connections',
mcpConnections: 'MCP Connections',

View File

@ -706,6 +706,7 @@ export default {
core: '核心',
connect: '连接',
system: '系统',
dsh: 'DeepSeek Harness',
agent: '智能体',
workspace: '工作区',
agentContext: '智能体上下文',

View File

@ -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',

View 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>

View File

@ -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',