mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-11-02 22:16:58 +08:00
feat(dsh): migrate SDK runtime and add pinned upgrades (#650)
This commit is contained in:
parent
063622602b
commit
0df511f43e
@ -38,7 +38,7 @@
|
|||||||
>
|
>
|
||||||
> Multi-user workspaces. Approval-gated sensitive actions. Full audit trail. Spring Boot Actuator health monitoring. Per-channel error isolation so one chat platform's outage doesn't take down the rest. One JAR in your environment; you control persisted data, and task content is sent only to model, channel, or tool services you explicitly configure.
|
> Multi-user workspaces. Approval-gated sensitive actions. Full audit trail. Spring Boot Actuator health monitoring. Per-channel error isolation so one chat platform's outage doesn't take down the rest. One JAR in your environment; you control persisted data, and task content is sent only to model, channel, or tool services you explicitly configure.
|
||||||
>
|
>
|
||||||
> **And underneath, a real Agent Runtime.** An employee is no longer welded to one reasoning loop. Choose the native StateGraph runtime for ReAct, Plan-and-Execute, Goals, and Team Runs, or run DeepSeek Harness as a managed external loop over authenticated JSON-RPC. Both paths converge on the same conversations, workspace boundaries, Tool Guard, event projection, and lifecycle controls.
|
> **And underneath, a real Agent Runtime.** An employee is no longer welded to one reasoning loop. Choose the native StateGraph runtime for ReAct, Plan-and-Execute, Goals, and Team Runs, or run DeepSeek Harness as a managed external loop over SDK JSON-RPC. Both paths converge on the same conversations, workspace boundaries, Tool Guard, event projection, and lifecycle controls.
|
||||||
|
|
||||||
Most AI tools die when their vendor has a bad day. Most forget you the moment the tab closes. Most give you a chatbox and call it a product.
|
Most AI tools die when their vendor has a bad day. Most forget you the moment the tab closes. Most give you a chatbox and call it a product.
|
||||||
|
|
||||||
@ -86,7 +86,7 @@ Same brain. Same memory. Same tools. Different doors.
|
|||||||
You hire coworkers, not chat boxes. Each one has a **Role**, a **Goal**, a **Backstory**, a runtime, a pixel-art avatar, and a color of their own — six built-in templates ship ready (General Assistant · Product Assistant · Research Analyst · Customer Support · Data Analyst · Code Reviewer). Employee identity and governance stay stable even when the execution engine changes.
|
You hire coworkers, not chat boxes. Each one has a **Role**, a **Goal**, a **Backstory**, a runtime, a pixel-art avatar, and a color of their own — six built-in templates ship ready (General Assistant · Product Assistant · Research Analyst · Customer Support · Data Analyst · Code Reviewer). Employee identity and governance stay stable even when the execution engine changes.
|
||||||
|
|
||||||
### Agent Runtime: native or DSH (2.2.0+)
|
### Agent Runtime: native or DSH (2.2.0+)
|
||||||
The `AgentRuntimeProvider` contract separates an employee from the engine that runs its turn. The **native runtime** keeps ReAct, Plan-and-Execute, persistent Goals, and Team Runs inside MateClaw. The **DSH runtime** manages `dsh-jsonrpc-agent` as an authenticated child process and streams thinking, text, tool calls, usage, completion, and cancellation back as normalized runtime events. DSH owns the external Agent loop; MateClaw still owns the session, workspace, credentials, tools, approvals, messages, and UI projection. Runtime availability and capabilities are validated before startup, and DSH can be installed, verified, connection-tested, enabled, or disabled from the console. [Configure DeepSeek Harness →](https://claw.mate.vip/docs/en/deepseek-harness)
|
The `AgentRuntimeProvider` contract separates an employee from the engine that runs its turn. The **native runtime** keeps ReAct, Plan-and-Execute, persistent Goals, and Team Runs inside MateClaw. The **DSH runtime** launches the official `dsh --profile sdk` CLI and normalizes its thinking, text, tools, usage, completion, and cancellation events. Managed installation pins `0.2.0-rc.1` (alternative: `0.1.7-rc.2`) with reviewed npm locks and Node.js 22. Upgrades copy runtime homes, check the candidate, and switch a retained generation; rollback restores the previous home/configuration without merging newer sessions. Package and real-model adapter checks passed on macOS arm64; Linux x64 and persisted V3→V4 history migration remain unverified. [Configure DeepSeek Harness →](https://claw.mate.vip/docs/en/deepseek-harness)
|
||||||
|
|
||||||
### Durable long tasks: checkpoint, restart, continue (2.2.0+)
|
### Durable long tasks: checkpoint, restart, continue (2.2.0+)
|
||||||
Persistent Goals turn work that takes hours into bounded, recoverable segments. The database preserves the goal checklist, continuation state, attempts, cooldowns, leases, and user input accepted while the worker is busy. After a single backend instance restarts, the supervisor reconciles the interrupted attempt, reads persisted checkpoints and artifacts, and schedules the next safe segment instead of asking you to repeat the task.
|
Persistent Goals turn work that takes hours into bounded, recoverable segments. The database preserves the goal checklist, continuation state, attempts, cooldowns, leases, and user input accepted while the worker is busy. After a single backend instance restarts, the supervisor reconciles the interrupted attempt, reads persisted checkpoints and artifacts, and schedules the next safe segment instead of asking you to repeat the task.
|
||||||
|
|||||||
@ -38,7 +38,7 @@
|
|||||||
>
|
>
|
||||||
> 多用户工作空间。敏感操作走审批。完整审计日志。Spring Boot Actuator 健康监控。单个渠道挂掉不影响其他渠道的错误隔离。一个 JAR 包跑在自己的环境里;持久化数据由你掌控,任务所需内容只会发送到你主动配置的模型、渠道或工具服务。
|
> 多用户工作空间。敏感操作走审批。完整审计日志。Spring Boot Actuator 健康监控。单个渠道挂掉不影响其他渠道的错误隔离。一个 JAR 包跑在自己的环境里;持久化数据由你掌控,任务所需内容只会发送到你主动配置的模型、渠道或工具服务。
|
||||||
>
|
>
|
||||||
> **底下是一套真正的 Agent Runtime。** 员工不再焊死在一套推理循环上:可以用原生 StateGraph 运行 ReAct、Plan-and-Execute、Goal 与 Team Run,也可以通过认证 JSON-RPC 把 DeepSeek Harness 作为受管理的外部循环。两条路径最终进入同一套会话、工作空间边界、Tool Guard、事件投影与生命周期控制。
|
> **底下是一套真正的 Agent Runtime。** 员工不再焊死在一套推理循环上:可以用原生 StateGraph 运行 ReAct、Plan-and-Execute、Goal 与 Team Run,也可以通过 SDK JSON-RPC 把 DeepSeek Harness 作为受管理的外部循环。两条路径最终进入同一套会话、工作空间边界、Tool Guard、事件投影与生命周期控制。
|
||||||
|
|
||||||
大多数 AI 工具一到厂商抽风那天就两手一摊。关一次标签页就忘了你是谁。给你一个聊天框,就敢叫产品。
|
大多数 AI 工具一到厂商抽风那天就两手一摊。关一次标签页就忘了你是谁。给你一个聊天框,就敢叫产品。
|
||||||
|
|
||||||
@ -86,7 +86,7 @@ MateClaw 的 **LLM Wiki** 把它消化成结构化页面,页面之间自己长
|
|||||||
你雇佣员工,不是开聊天框。每位有**角色**、**目标**、**背景故事**、运行时、像素艺术头像与专属配色——6 个内置模板(通用助手 · 产品助理 · 研究分析师 · 客服助理 · 数据分析师 · 代码审查员)开箱可用。即使更换执行引擎,员工身份和治理边界仍保持不变。
|
你雇佣员工,不是开聊天框。每位有**角色**、**目标**、**背景故事**、运行时、像素艺术头像与专属配色——6 个内置模板(通用助手 · 产品助理 · 研究分析师 · 客服助理 · 数据分析师 · 代码审查员)开箱可用。即使更换执行引擎,员工身份和治理边界仍保持不变。
|
||||||
|
|
||||||
### Agent Runtime:Native 或 DSH(2.2.0+)
|
### Agent Runtime:Native 或 DSH(2.2.0+)
|
||||||
`AgentRuntimeProvider` contract 把员工与实际执行回合的引擎分开。**Native Runtime** 在 MateClaw 内运行 ReAct、Plan-and-Execute、Persistent Goal 与 Team Run;**DSH Runtime** 把 `dsh-jsonrpc-agent` 作为认证子进程管理,并将思考、文本、工具调用、用量、完成与取消统一映射为 runtime event。DSH 掌管外部 Agent loop,MateClaw 继续掌管 session、workspace、凭证、工具、审批、消息和 UI 投影。启动前会校验 runtime 可用性与能力;控制台可完成 DSH 的安装、配置、校验、连接测试和启停。[配置 DeepSeek Harness →](https://claw.mate.vip/docs/zh/deepseek-harness)
|
`AgentRuntimeProvider` contract 将员工与执行引擎分离。**Native Runtime** 在 MateClaw 内运行 ReAct、Plan-and-Execute、Persistent Goal 与 Team Run;**DSH Runtime** 启动官方 `dsh --profile sdk` CLI,统一映射思考、文本、工具、用量、完成与取消事件。受管理安装要求 Node.js 22,使用受审查 npm 锁文件固定 `0.2.0-rc.1`(另选 `0.1.7-rc.2`)。升级复制 home、检查候选并切换保留的代次;回滚恢复旧 home/配置,不合并升级后的新会话。实际包及真实模型适配器检查已在 macOS arm64 通过;Linux x64 和持久化 V3→V4 历史迁移仍待验证。[配置 DeepSeek Harness →](https://claw.mate.vip/docs/zh/deepseek-harness)
|
||||||
|
|
||||||
### 持久长任务:检查点、重启、继续(2.2.0+)
|
### 持久长任务:检查点、重启、继续(2.2.0+)
|
||||||
Persistent Goal 把需要数小时的工作拆成有界、可恢复的执行段。数据库会保存目标清单、continuation 状态、attempt、冷却、lease,以及员工忙碌期间已经接收的用户输入。单后端实例重启后,supervisor 会先核对被中断的 attempt,读取持久检查点和已有产物,再调度下一段安全工作,不要求用户重新描述任务。
|
Persistent Goal 把需要数小时的工作拆成有界、可恢复的执行段。数据库会保存目标清单、continuation 状态、attempt、冷却、lease,以及员工忙碌期间已经接收的用户输入。单后端实例重启后,supervisor 会先核对被中断的 attempt,读取持久检查点和已有产物,再调度下一段安全工作,不要求用户重新描述任务。
|
||||||
|
|||||||
@ -0,0 +1,66 @@
|
|||||||
|
package vip.mate.agent.runtime.dsh;
|
||||||
|
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import vip.mate.agent.runtime.dsh.management.DshRuntimeConfiguration;
|
||||||
|
import java.nio.file.*;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/** Immutable SDK argv. Paths are never interpreted as a shell command. */
|
||||||
|
public record DshLaunchSpec(List<String> command, Path home, Path cwd) {
|
||||||
|
public DshLaunchSpec { command = List.copyOf(command); }
|
||||||
|
|
||||||
|
public static DshLaunchSpec create(DshRuntimeConfiguration config, Object workspaceId, Object agentId, Path cwd) {
|
||||||
|
if (config.migrationRequired()) throw new IllegalArgumentException("MIGRATION_REQUIRED: replace legacy DSH command/Cordis configuration with an SDK executable path");
|
||||||
|
if (!"sdk".equals(config.profile())) throw new IllegalArgumentException("DSH_PROFILE_INVALID: profile must be sdk");
|
||||||
|
Path executable = Path.of(config.executablePath());
|
||||||
|
if (!executable.isAbsolute() || !Files.isRegularFile(executable) || !Files.isExecutable(executable))
|
||||||
|
throw new IllegalArgumentException("DSH_EXECUTABLE_UNAVAILABLE: configure an absolute executable path");
|
||||||
|
if (cwd == null || !Files.isDirectory(cwd)) throw new IllegalArgumentException("DSH_CWD_UNAVAILABLE");
|
||||||
|
List<String> argv = new ArrayList<>(List.of(executable.normalize().toString(), "--profile", "sdk"));
|
||||||
|
for (String patch : config.patchPaths()) {
|
||||||
|
Path path = Path.of(patch);
|
||||||
|
if (!path.isAbsolute() || !Files.isRegularFile(path)) throw new IllegalArgumentException("DSH_PATCH_UNAVAILABLE");
|
||||||
|
argv.add("--patch"); argv.add(path.normalize().toString());
|
||||||
|
}
|
||||||
|
Path root = Path.of(config.homeRoot()).toAbsolutePath().normalize();
|
||||||
|
Path home = root.resolve(opaque(workspaceId)).resolve(opaque(agentId));
|
||||||
|
return new DshLaunchSpec(argv, home, cwd.toAbsolutePath().normalize());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String opaque(Object id) {
|
||||||
|
return UUID.nameUUIDFromBytes(String.valueOf(id).getBytes(StandardCharsets.UTF_8)).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProcessBuilder processBuilder(Map<String, String> environment) throws IOException {
|
||||||
|
Path root = home.getParent().getParent();
|
||||||
|
// Preflight every managed component before any directory creation can follow a link.
|
||||||
|
requireDirectoryOrMissing(root);
|
||||||
|
requireDirectoryOrMissing(home.getParent());
|
||||||
|
requireDirectoryOrMissing(home);
|
||||||
|
Files.createDirectories(root);
|
||||||
|
createHomeDirectory(home.getParent());
|
||||||
|
createHomeDirectory(home);
|
||||||
|
if (!home.toRealPath().startsWith(root.toRealPath())) throw new IOException("DSH_HOME_ESCAPE");
|
||||||
|
ProcessBuilder builder = new ProcessBuilder(command).directory(cwd.toFile());
|
||||||
|
builder.environment().clear();
|
||||||
|
builder.environment().putAll(environment);
|
||||||
|
builder.environment().remove("DSH_CORDIS_CONFIG");
|
||||||
|
builder.environment().put("DSH_HOME", home.toString());
|
||||||
|
builder.environment().put("DSH_CWD", cwd.toString());
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
private static void requireDirectoryOrMissing(Path path) throws IOException {
|
||||||
|
if (Files.isSymbolicLink(path)
|
||||||
|
|| (Files.exists(path, LinkOption.NOFOLLOW_LINKS) && !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)))
|
||||||
|
throw new IOException("DSH_HOME_ESCAPE: home components must be real directories");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void createHomeDirectory(Path path) throws IOException {
|
||||||
|
requireDirectoryOrMissing(path);
|
||||||
|
try { Files.createDirectory(path); }
|
||||||
|
catch (FileAlreadyExistsException exists) { requireDirectoryOrMissing(path); }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -1,5 +1,12 @@
|
|||||||
package vip.mate.agent.runtime.dsh;
|
package vip.mate.agent.runtime.dsh;
|
||||||
|
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.io.Closeable;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import vip.mate.workspace.core.service.MemberFileIsolation;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@ -17,6 +24,7 @@ import vip.mate.agent.runtime.contract.RuntimeCapabilities;
|
|||||||
import vip.mate.agent.runtime.contract.RuntimeContextUsage;
|
import vip.mate.agent.runtime.contract.RuntimeContextUsage;
|
||||||
import vip.mate.agent.runtime.contract.RuntimeValidation;
|
import vip.mate.agent.runtime.contract.RuntimeValidation;
|
||||||
import vip.mate.agent.runtime.dsh.management.DshRuntimeConfigService;
|
import vip.mate.agent.runtime.dsh.management.DshRuntimeConfigService;
|
||||||
|
import vip.mate.agent.runtime.dsh.management.DshGenerationStore;
|
||||||
import vip.mate.agent.runtime.dsh.management.DshRuntimeConfiguration;
|
import vip.mate.agent.runtime.dsh.management.DshRuntimeConfiguration;
|
||||||
import vip.mate.agent.AgentService;
|
import vip.mate.agent.AgentService;
|
||||||
import vip.mate.config.ConversationWindowProperties;
|
import vip.mate.config.ConversationWindowProperties;
|
||||||
@ -24,21 +32,16 @@ import vip.mate.llm.model.ModelConfigEntity;
|
|||||||
import vip.mate.llm.model.ModelProviderEntity;
|
import vip.mate.llm.model.ModelProviderEntity;
|
||||||
import vip.mate.llm.service.ModelConfigService;
|
import vip.mate.llm.service.ModelConfigService;
|
||||||
import vip.mate.llm.service.ModelProviderService;
|
import vip.mate.llm.service.ModelProviderService;
|
||||||
|
|
||||||
import java.io.BufferedReader;
|
|
||||||
import java.io.BufferedWriter;
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStreamReader;
|
|
||||||
import java.io.OutputStreamWriter;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adapter for the official DeepSeek Harness SDK JSON-RPC runtime.
|
* Adapter for the official DeepSeek Harness SDK JSON-RPC runtime.
|
||||||
@ -55,6 +58,8 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
|||||||
private final ModelProviderService modelProviderService;
|
private final ModelProviderService modelProviderService;
|
||||||
private final DshRuntimeConfigService runtimeConfigService;
|
private final DshRuntimeConfigService runtimeConfigService;
|
||||||
private final ConversationWindowProperties windowProperties;
|
private final ConversationWindowProperties windowProperties;
|
||||||
|
@Value("${mateclaw.agent.runtime.dsh.initialize-timeout-ms:30000}")
|
||||||
|
private long initializeTimeoutMs = 30000;
|
||||||
private static final int DEFAULT_MAX_OUTPUT_TOKENS = 4096;
|
private static final int DEFAULT_MAX_OUTPUT_TOKENS = 4096;
|
||||||
private static final int DEFAULT_CONTEXT_WINDOW = 128000;
|
private static final int DEFAULT_CONTEXT_WINDOW = 128000;
|
||||||
|
|
||||||
@ -69,32 +74,16 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
|||||||
this.modelProviderService = modelProviderService;
|
this.modelProviderService = modelProviderService;
|
||||||
this.runtimeConfigService = runtimeConfigService;
|
this.runtimeConfigService = runtimeConfigService;
|
||||||
this.windowProperties = windowProperties;
|
this.windowProperties = windowProperties;
|
||||||
DshRuntimeConfiguration configuration = runtimeConfig();
|
// Validate lazily so administrators can repair invalid stored settings after startup.
|
||||||
log.info("[DSH] runtime configured: command={}, cordisConfig={}", configuration.executablePath(),
|
log.info("[DSH] SDK runtime adapter initialized");
|
||||||
configuration.cordisConfigPath().isBlank() ? "<empty>" : configuration.cordisConfigPath());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private DshRuntimeConfiguration runtimeConfig() {
|
private DshRuntimeConfiguration runtimeConfig() {
|
||||||
DshRuntimeConfiguration raw = runtimeConfigService.resolve();
|
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();
|
String cwd = raw.workingDirectory();
|
||||||
if (cwd == null || cwd.isBlank()) cwd = System.getProperty("user.dir");
|
if (cwd == null || cwd.isBlank()) cwd = System.getProperty("user.dir");
|
||||||
return new DshRuntimeConfiguration(command, cordis, cwd, raw.baseUrl(), raw.modelName(), raw.apiKey());
|
return new DshRuntimeConfiguration(raw.executablePath(), raw.cordisConfigPath(), cwd,
|
||||||
}
|
raw.baseUrl(), raw.modelName(), raw.apiKey(), raw.profile(), raw.patchPaths(), raw.homeRoot());
|
||||||
|
|
||||||
private String resolveCordisConfig(String configuredPath) {
|
|
||||||
if (configuredPath == null || configuredPath.isBlank()) return "";
|
|
||||||
Path path = Path.of(configuredPath).toAbsolutePath().normalize();
|
|
||||||
if (Files.isRegularFile(path)) return path.toString();
|
|
||||||
// The documented source checkout path points at the package directory;
|
|
||||||
// the checked-in composition lives below its runtime subdirectory.
|
|
||||||
Path packageDirectory = Files.isDirectory(path) ? path : path.getParent();
|
|
||||||
Path packagedConfig = packageDirectory == null
|
|
||||||
? path
|
|
||||||
: packageDirectory.resolve("runtime").resolve("cordis.yml");
|
|
||||||
return Files.isRegularFile(packagedConfig) ? packagedConfig.toString() : path.toString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -111,15 +100,10 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
|||||||
if (session.workingDirectory() == null || !Files.isDirectory(session.workingDirectory())) {
|
if (session.workingDirectory() == null || !Files.isDirectory(session.workingDirectory())) {
|
||||||
return RuntimeValidation.invalid("dsh.working_directory_unavailable", "DSH working directory is unavailable");
|
return RuntimeValidation.invalid("dsh.working_directory_unavailable", "DSH working directory is unavailable");
|
||||||
}
|
}
|
||||||
if (configuration.executablePath().isBlank()) {
|
try {
|
||||||
return RuntimeValidation.invalid("dsh.command_missing", "DSH runtime command is not configured");
|
DshLaunchSpec.create(configuration, session.workspaceId(), session.agentId(), session.workingDirectory());
|
||||||
}
|
} catch (IllegalArgumentException error) {
|
||||||
Path executable = Path.of(commandLine(configuration.executablePath()).get(0));
|
return RuntimeValidation.invalid(configuration.migrationRequired() ? "dsh.migration_required" : "dsh.config_invalid", error.getMessage());
|
||||||
if (!executable.isAbsolute() || !Files.isExecutable(executable)) {
|
|
||||||
return RuntimeValidation.invalid("dsh.command_unavailable", "DSH runtime command is not executable");
|
|
||||||
}
|
|
||||||
if (!configuration.cordisConfigPath().isBlank() && !Files.isRegularFile(Path.of(configuration.cordisConfigPath()))) {
|
|
||||||
return RuntimeValidation.invalid("dsh.cordis_missing", "DSH Cordis configuration is unavailable");
|
|
||||||
}
|
}
|
||||||
return RuntimeValidation.success();
|
return RuntimeValidation.success();
|
||||||
}
|
}
|
||||||
@ -131,7 +115,7 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
|||||||
|
|
||||||
public Map<String, Object> diagnostics() {
|
public Map<String, Object> diagnostics() {
|
||||||
DshRuntimeConfiguration configuration = runtimeConfig();
|
DshRuntimeConfiguration configuration = runtimeConfig();
|
||||||
Path executable = configuration.executablePath().isBlank() ? null : Path.of(commandLine(configuration.executablePath()).get(0));
|
Path executable = configuration.executablePath().isBlank() ? null : Path.of(configuration.executablePath());
|
||||||
return Map.of(
|
return Map.of(
|
||||||
"type", type(),
|
"type", type(),
|
||||||
"commandConfigured", !configuration.executablePath().isBlank(),
|
"commandConfigured", !configuration.executablePath().isBlank(),
|
||||||
@ -165,7 +149,7 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public AgentRuntimeConnection start(RuntimeSession session) {
|
public AgentRuntimeConnection start(RuntimeSession session) {
|
||||||
if (vip.mate.workspace.core.service.MemberFileIsolation.isEnabled()) {
|
if (MemberFileIsolation.isEnabled()) {
|
||||||
throw new SecurityException("DSH runtime is unavailable in member file isolation mode");
|
throw new SecurityException("DSH runtime is unavailable in member file isolation mode");
|
||||||
}
|
}
|
||||||
RuntimeValidation validation = validate(session);
|
RuntimeValidation validation = validate(session);
|
||||||
@ -177,25 +161,26 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
|||||||
agent.setWorkspaceId(session.workspaceId());
|
agent.setWorkspaceId(session.workspaceId());
|
||||||
agent.setModelName(session.modelName());
|
agent.setModelName(session.modelName());
|
||||||
AtomicReference<Process> activeProcess = new AtomicReference<>();
|
AtomicReference<Process> activeProcess = new AtomicReference<>();
|
||||||
|
AtomicBoolean cancelled = new AtomicBoolean();
|
||||||
AtomicReference<RuntimeContextUsage> latestUsage = new AtomicReference<>(
|
AtomicReference<RuntimeContextUsage> latestUsage = new AtomicReference<>(
|
||||||
new RuntimeContextUsage(0, 0, 0));
|
new RuntimeContextUsage(0, 0, 0));
|
||||||
return new AgentRuntimeConnection() {
|
return new AgentRuntimeConnection() {
|
||||||
@Override
|
@Override
|
||||||
public Flux<RuntimeEvent> prompt(String message) {
|
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)
|
session.workingDirectory(), activeProcess, latestUsage, cancelled)
|
||||||
.map(DshRuntimeService.this::toRuntimeEvent);
|
.map(DshRuntimeService.this::toRuntimeEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public reactor.core.publisher.Mono<Void> cancel() {
|
public Mono<Void> cancel() {
|
||||||
return reactor.core.publisher.Mono.fromRunnable(
|
return Mono.fromRunnable(
|
||||||
() -> cancelProcess(activeProcess.get()));
|
() -> { cancelled.set(true); cancelProcess(activeProcess.get()); });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public reactor.core.publisher.Mono<RuntimeContextUsage> contextUsage() {
|
public Mono<RuntimeContextUsage> contextUsage() {
|
||||||
return reactor.core.publisher.Mono.just(latestUsage.get());
|
return Mono.just(latestUsage.get());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -210,6 +195,7 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
|||||||
}
|
}
|
||||||
RuntimeEventType type = switch (delta.eventType() == null ? "" : delta.eventType()) {
|
RuntimeEventType type = switch (delta.eventType() == null ? "" : delta.eventType()) {
|
||||||
case "done" -> RuntimeEventType.COMPLETED;
|
case "done" -> RuntimeEventType.COMPLETED;
|
||||||
|
case "_usage_final" -> RuntimeEventType.CONTEXT_USAGE;
|
||||||
case "error" -> RuntimeEventType.FAILED;
|
case "error" -> RuntimeEventType.FAILED;
|
||||||
case "cancelled" -> RuntimeEventType.CANCELLED;
|
case "cancelled" -> RuntimeEventType.CANCELLED;
|
||||||
case "tool_call_started" -> RuntimeEventType.TOOL_STARTED;
|
case "tool_call_started" -> RuntimeEventType.TOOL_STARTED;
|
||||||
@ -224,24 +210,29 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
|||||||
|
|
||||||
public Flux<AgentService.StreamDelta> stream(AgentEntity agent, String message,
|
public Flux<AgentService.StreamDelta> stream(AgentEntity agent, String message,
|
||||||
String conversationId, String modelName) {
|
String conversationId, String modelName) {
|
||||||
DshRuntimeConfiguration configuration = runtimeConfig();
|
|
||||||
return stream(agent, message, conversationId, modelName,
|
return stream(agent, message, conversationId, modelName,
|
||||||
resolveWorkingDirectory(null, configuration), new AtomicReference<>(),
|
null, new AtomicReference<>(),
|
||||||
new AtomicReference<>(new RuntimeContextUsage(0, 0, 0)));
|
new AtomicReference<>(new RuntimeContextUsage(0, 0, 0)), new AtomicBoolean());
|
||||||
}
|
}
|
||||||
|
|
||||||
private Flux<AgentService.StreamDelta> stream(AgentEntity agent, String message,
|
private Flux<AgentService.StreamDelta> stream(AgentEntity agent, String message,
|
||||||
String conversationId, String modelName,
|
String conversationId, String modelName,
|
||||||
Path workingDirectory,
|
Path workingDirectory,
|
||||||
AtomicReference<Process> processRef,
|
AtomicReference<Process> processRef,
|
||||||
AtomicReference<RuntimeContextUsage> latestUsage) {
|
AtomicReference<RuntimeContextUsage> latestUsage, AtomicBoolean cancelled) {
|
||||||
if (vip.mate.workspace.core.service.MemberFileIsolation.isEnabled()) {
|
if (MemberFileIsolation.isEnabled()) {
|
||||||
return Flux.error(new SecurityException("DSH runtime is unavailable in member file isolation mode"));
|
return Flux.error(new SecurityException("DSH runtime is unavailable in member file isolation mode"));
|
||||||
}
|
}
|
||||||
return Flux.<AgentService.StreamDelta>create(sink -> {
|
return Flux.<AgentService.StreamDelta>create(sink -> {
|
||||||
Process process = null;
|
Process process = null;
|
||||||
|
DshGenerationStore.Lease lease = null;
|
||||||
|
DshSdkTurn turn = null;
|
||||||
|
String credential = "";
|
||||||
try {
|
try {
|
||||||
if (sink.isCancelled()) return;
|
if (sink.isCancelled()) return;
|
||||||
|
if (cancelled.get()) throw new InterruptedException("DSH_CANCELLED");
|
||||||
|
if (runtimeConfigService.generations() != null)
|
||||||
|
lease = runtimeConfigService.generations().acquireLease(String.valueOf(agent.getWorkspaceId()), String.valueOf(agent.getId()));
|
||||||
DshRuntimeConfiguration configuration = runtimeConfig();
|
DshRuntimeConfiguration configuration = runtimeConfig();
|
||||||
RuntimeSession session = new RuntimeSession(
|
RuntimeSession session = new RuntimeSession(
|
||||||
conversationId,
|
conversationId,
|
||||||
@ -249,7 +240,7 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
|||||||
agent.getId(),
|
agent.getId(),
|
||||||
agent.getWorkspaceId(),
|
agent.getWorkspaceId(),
|
||||||
modelName,
|
modelName,
|
||||||
workingDirectory,
|
workingDirectory == null ? resolveWorkingDirectory(null, configuration) : workingDirectory,
|
||||||
Map.of());
|
Map.of());
|
||||||
// Each prompt runs in a fresh child process. DSH persists its
|
// Each prompt runs in a fresh child process. DSH persists its
|
||||||
// own session log, so reusing the MateClaw conversation id
|
// own session log, so reusing the MateClaw conversation id
|
||||||
@ -259,6 +250,7 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
|||||||
String requestedModel = modelName == null || modelName.isBlank() ? configuration.modelName() : modelName;
|
String requestedModel = modelName == null || modelName.isBlank() ? configuration.modelName() : modelName;
|
||||||
ModelConfigEntity model = resolveModel(requestedModel);
|
ModelConfigEntity model = resolveModel(requestedModel);
|
||||||
ModelProviderEntity provider = resolveProvider(model);
|
ModelProviderEntity provider = resolveProvider(model);
|
||||||
|
credential = firstNonBlank(configuration.apiKey(), provider == null ? null : provider.getApiKey());
|
||||||
String effectiveModelName = resolveModelName(requestedModel, model);
|
String effectiveModelName = resolveModelName(requestedModel, model);
|
||||||
int maxOutputTokens = resolveMaxOutputTokens(model, windowProperties.getDefaultMaxInputTokens());
|
int maxOutputTokens = resolveMaxOutputTokens(model, windowProperties.getDefaultMaxInputTokens());
|
||||||
log.debug("[DSH] model route: requestedModel={}, effectiveModel={}, provider={}, apiKeyConfigured={}, baseUrlConfigured={}",
|
log.debug("[DSH] model route: requestedModel={}, effectiveModel={}, provider={}, apiKeyConfigured={}, baseUrlConfigured={}",
|
||||||
@ -267,115 +259,142 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
|||||||
provider == null ? "<missing>" : provider.getProviderId(),
|
provider == null ? "<missing>" : provider.getProviderId(),
|
||||||
provider != null && provider.getApiKey() != null && !provider.getApiKey().isBlank(),
|
provider != null && provider.getApiKey() != null && !provider.getApiKey().isBlank(),
|
||||||
provider != null && provider.getBaseUrl() != null && !provider.getBaseUrl().isBlank());
|
provider != null && provider.getBaseUrl() != null && !provider.getBaseUrl().isBlank());
|
||||||
List<String> command = commandLine(configuration.executablePath());
|
DshLaunchSpec launch = DshLaunchSpec.create(configuration, agent.getWorkspaceId(), agent.getId(), session.workingDirectory());
|
||||||
ProcessBuilder builder = new ProcessBuilder(command)
|
process = launch.processBuilder(childEnvironment(System.getenv(), session, configuration, provider)).start();
|
||||||
.directory(session.workingDirectory().toFile())
|
|
||||||
.redirectError(ProcessBuilder.Redirect.PIPE);
|
|
||||||
Map<String, String> environment = builder.environment();
|
|
||||||
Map<String, String> childEnvironment = childEnvironment(environment, session, configuration, provider);
|
|
||||||
environment.clear();
|
|
||||||
environment.putAll(childEnvironment);
|
|
||||||
log.debug("[DSH] child environment: keys={}, cordisConfig={}, exists={}",
|
|
||||||
environment.keySet(),
|
|
||||||
environment.getOrDefault("DSH_CORDIS_CONFIG", "<empty>"),
|
|
||||||
!configuration.cordisConfigPath().isBlank() && Files.isRegularFile(Path.of(configuration.cordisConfigPath())));
|
|
||||||
process = builder.start();
|
|
||||||
processRef.set(process);
|
processRef.set(process);
|
||||||
if (sink.isCancelled()) {
|
|
||||||
cancelProcess(process);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Process startedProcess = process;
|
Process startedProcess = process;
|
||||||
Thread stderrLogger = new Thread(() -> logProcessStderr(startedProcess),
|
sink.onCancel(() -> { cancelled.set(true); cancelProcess(startedProcess); });
|
||||||
"dsh-runtime-stderr-" + conversationId);
|
if (sink.isCancelled()) return;
|
||||||
stderrLogger.setDaemon(true);
|
if (cancelled.get()) throw new InterruptedException("DSH_CANCELLED");
|
||||||
stderrLogger.start();
|
try (DshSdkProcess sdk = new DshSdkProcess(process, objectMapper,
|
||||||
sink.onCancel(() -> cancelProcess(startedProcess));
|
firstNonBlank(configuration.apiKey(), provider == null ? null : provider.getApiKey()))) {
|
||||||
try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(
|
sdk.initialize(Map.of("cwd", session.workingDirectory().toString(),
|
||||||
process.getOutputStream(), StandardCharsets.UTF_8));
|
"provider", "deepseek-official", "model", effectiveModelName, "maxTokens", maxOutputTokens),
|
||||||
BufferedReader reader = new BufferedReader(new InputStreamReader(
|
Duration.ofMillis(initializeTimeoutMs));
|
||||||
process.getInputStream(), StandardCharsets.UTF_8))) {
|
|
||||||
send(writer, request("initialize", "init-" + conversationId, Map.of(
|
|
||||||
"cwd", session.workingDirectory().toString(),
|
|
||||||
"provider", "deepseek-official",
|
|
||||||
"model", effectiveModelName,
|
|
||||||
"maxTokens", maxOutputTokens)));
|
|
||||||
awaitResponse(reader, "init-" + conversationId);
|
|
||||||
long sequence = 0;
|
long sequence = 0;
|
||||||
sink.next(RuntimeEventProjector.project(RuntimeEvent.of(
|
sink.next(RuntimeEventProjector.project(RuntimeEvent.of(conversationId, sequence++,
|
||||||
conversationId, sequence++, RuntimeEventType.RUNTIME_READY, null,
|
RuntimeEventType.RUNTIME_READY, null, Map.of("runtimeProvider", "dsh"))));
|
||||||
Map.of("runtimeProvider", "dsh", "runtimeCommand", configuration.executablePath()))));
|
|
||||||
|
|
||||||
String promptId = "prompt-" + conversationId;
|
String promptId = "prompt-" + conversationId;
|
||||||
send(writer, request("session/prompt", promptId, Map.of(
|
turn = new DshSdkTurn(dshSessionId, promptId);
|
||||||
"sessionId", dshSessionId,
|
sdk.send("session/prompt", promptId, Map.of("sessionId", dshSessionId,
|
||||||
"contentBlocks", List.of(Map.of("type", "text", "text", message)))));
|
"contentBlocks", List.of(Map.of("type", "text", "text", message))));
|
||||||
|
while (!turn.complete() && !sink.isCancelled()) {
|
||||||
// DSH may emit session events before the JSON-RPC response
|
JsonNode payload = sdk.next(Duration.ofMinutes(10));
|
||||||
// for session/prompt. Read both on the same loop so those
|
if (payload.has("method") && payload.has("id")) {
|
||||||
// notifications are not discarded while waiting for id.
|
sdk.send(errorResponse(payload.get("id"), -32601, "Unsupported runtime request"));
|
||||||
boolean terminal = false;
|
|
||||||
boolean promptResponseReceived = false;
|
|
||||||
String line;
|
|
||||||
while (!terminal && (line = reader.readLine()) != null) {
|
|
||||||
JsonNode payload = objectMapper.readTree(line);
|
|
||||||
if (payload == null) continue;
|
|
||||||
if (payload.has("id") && promptId.equals(payload.path("id").asText(null))) {
|
|
||||||
promptResponseReceived = true;
|
|
||||||
log.debug("[DSH] prompt response received: id={}, error={}", promptId,
|
|
||||||
payload.has("error"));
|
|
||||||
if (payload.has("error")) {
|
|
||||||
throw new IllegalStateException(payload.path("error").path("message")
|
|
||||||
.asText("DSH prompt failed"));
|
|
||||||
}
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!payload.has("method")) continue;
|
for (JsonNode event : turn.accept(payload)) {
|
||||||
String method = payload.path("method").asText();
|
String type = event.path("type").asText();
|
||||||
JsonNode params = payload.path("params");
|
if ("assistant/message".equals(type)) {
|
||||||
if (payload.has("id")) {
|
for (JsonNode record : event.path("data").path("stream")) {
|
||||||
send(writer, errorResponse(payload.get("id"), -32601, "MateClaw does not support runtime request: " + method));
|
JsonNode part = record.has("chunk") ? record.path("chunk") : record;
|
||||||
continue;
|
if ("reasoning-chunks".equals(record.path("type").asText())) {
|
||||||
}
|
StringBuilder reasoning = new StringBuilder();
|
||||||
if ("session.event".equals(method)) {
|
for (JsonNode text : record.path("texts")) reasoning.append(text.asText(""));
|
||||||
JsonNode event = params.path("event");
|
sink.next(RuntimeEventProjector.project(RuntimeEvent.of(conversationId, sequence++,
|
||||||
log.debug("[DSH] event: type={}", event.path("type").asText("<missing>"));
|
RuntimeEventType.THINKING_DELTA, reasoning.toString(), Map.of())));
|
||||||
logChunkMetadata(event);
|
} else if ("reasoning-delta".equals(part.path("type").asText())) {
|
||||||
logTerminalReason(event);
|
sink.next(RuntimeEventProjector.project(RuntimeEvent.of(conversationId, sequence++,
|
||||||
RuntimeEvent mapped = mapEvent(conversationId, sequence++, event);
|
RuntimeEventType.THINKING_DELTA, part.path("text").asText(""), Map.of())));
|
||||||
if (mapped != null) {
|
|
||||||
if (mapped.type() == RuntimeEventType.CONTEXT_USAGE) {
|
|
||||||
latestUsage.set(usageFrom(mapped));
|
|
||||||
}
|
|
||||||
sink.next(RuntimeEventProjector.project(mapped));
|
|
||||||
terminal = mapped.terminal();
|
|
||||||
}
|
|
||||||
} else if ("session.status".equals(method)
|
|
||||||
&& promptResponseReceived
|
|
||||||
&& "idle".equals(params.path("status").asText())) {
|
|
||||||
log.debug("[DSH] session idle after prompt");
|
|
||||||
sink.next(RuntimeEventProjector.project(RuntimeEvent.terminal(
|
|
||||||
conversationId, sequence++, RuntimeEventType.COMPLETED, Map.of())));
|
|
||||||
terminal = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!terminal) {
|
} else if ("tool/call".equals(type) || "tool/result".equals(type)) {
|
||||||
int exitCode = process.waitFor();
|
sink.next(RuntimeEventProjector.project(mapEvent(conversationId, sequence++, event)));
|
||||||
sink.next(RuntimeEventProjector.project(RuntimeEvent.terminal(
|
|
||||||
conversationId, sequence, RuntimeEventType.FAILED,
|
|
||||||
Map.of("error", "DSH runtime closed before completion (exit=" + exitCode + ")"))));
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!sink.isCancelled()) {
|
||||||
|
if (!turn.answer().isEmpty()) sink.next(RuntimeEventProjector.project(RuntimeEvent.of(
|
||||||
|
conversationId, sequence++, RuntimeEventType.ASSISTANT_DELTA, turn.answer(), Map.of())));
|
||||||
|
latestUsage.set(new RuntimeContextUsage(turn.inputTokens(), turn.outputTokens(), 0));
|
||||||
|
sink.next(RuntimeEventProjector.project(RuntimeEvent.of(conversationId, sequence++, RuntimeEventType.CONTEXT_USAGE,
|
||||||
|
null, Map.of("inputTokens", turn.inputTokens(), "outputTokens", turn.outputTokens(),
|
||||||
|
"promptTokens", turn.inputTokens(), "completionTokens", turn.outputTokens()))));
|
||||||
|
sink.next(RuntimeEventProjector.project(RuntimeEvent.terminal(conversationId, sequence,
|
||||||
|
turn.terminalType(), turn.terminalType() == RuntimeEventType.COMPLETED ? Map.of()
|
||||||
|
: Map.of("code", "DSH_INCOMPLETE_TURN", "error", "DSH turn ended: " + turn.reason()))));
|
||||||
sink.complete();
|
sink.complete();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (Exception error) {
|
} catch (Exception error) {
|
||||||
sink.error(new IllegalStateException("DSH runtime unavailable: " + error.getMessage(), error));
|
if (!sink.isCancelled() && turn != null && !turn.answer().isEmpty())
|
||||||
|
sink.next(RuntimeEventProjector.project(RuntimeEvent.of(conversationId, 0,
|
||||||
|
RuntimeEventType.ASSISTANT_DELTA, turn.answer(), Map.of())));
|
||||||
|
if (cancelled.get()) {
|
||||||
|
if (!sink.isCancelled()) {
|
||||||
|
sink.next(RuntimeEventProjector.project(RuntimeEvent.terminal(conversationId, 0, RuntimeEventType.CANCELLED, Map.of())));
|
||||||
|
sink.complete();
|
||||||
|
}
|
||||||
|
} else sink.error(new IllegalStateException("DSH runtime unavailable: " + DshSdkProcess.redact(error.getMessage(), credential)));
|
||||||
if (process != null) process.destroyForcibly();
|
if (process != null) process.destroyForcibly();
|
||||||
} finally {
|
} finally {
|
||||||
|
cancelProcess(process);
|
||||||
if (process != null) processRef.compareAndSet(process, null);
|
if (process != null) processRef.compareAndSet(process, null);
|
||||||
|
if (lease != null) lease.close();
|
||||||
}
|
}
|
||||||
}).subscribeOn(Schedulers.boundedElastic());
|
}).subscribeOn(Schedulers.boundedElastic());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Candidate checks deliberately bypass admission; the upgrade coordinator owns its gate. */
|
||||||
|
public Map<String, Object> testConnection(DshRuntimeConfiguration configuration) {
|
||||||
|
return healthCheck(configuration, false, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> testTask(DshRuntimeConfiguration configuration) {
|
||||||
|
return healthCheck(configuration, true, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> testConnectionAtHome(DshRuntimeConfiguration configuration, Path home) {
|
||||||
|
return healthCheck(configuration, false, home);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> testTaskAtHome(DshRuntimeConfiguration configuration, Path home) {
|
||||||
|
return healthCheck(configuration, true, home);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> healthCheck(DshRuntimeConfiguration configuration, boolean task, Path home) {
|
||||||
|
long started = System.currentTimeMillis();
|
||||||
|
try {
|
||||||
|
if (MemberFileIsolation.isEnabled()) throw new SecurityException("DSH_MEMBER_ISOLATION_UNSUPPORTED");
|
||||||
|
Path cwd = Path.of(configuration.workingDirectory());
|
||||||
|
DshLaunchSpec launch = DshLaunchSpec.create(configuration, "health", "health", cwd);
|
||||||
|
if (home != null) launch = new DshLaunchSpec(launch.command(), home.toAbsolutePath().normalize(), launch.cwd());
|
||||||
|
ModelConfigEntity model = resolveModel(configuration.modelName());
|
||||||
|
ModelProviderEntity provider = resolveProvider(model);
|
||||||
|
RuntimeSession session = new RuntimeSession("health", "health", 0L, 0L, configuration.modelName(), cwd, Map.of());
|
||||||
|
Process process = launch.processBuilder(childEnvironment(System.getenv(), session, configuration, provider)).start();
|
||||||
|
try (DshSdkProcess sdk = new DshSdkProcess(process, objectMapper,
|
||||||
|
firstNonBlank(configuration.apiKey(), provider == null ? null : provider.getApiKey()))) {
|
||||||
|
sdk.initialize(Map.of("cwd", cwd.toString(), "provider", "deepseek-official",
|
||||||
|
"model", resolveModelName(configuration.modelName(), model),
|
||||||
|
"maxTokens", task ? 64 : resolveMaxOutputTokens(model, windowProperties.getDefaultMaxInputTokens())),
|
||||||
|
Duration.ofMillis(initializeTimeoutMs));
|
||||||
|
if (task) {
|
||||||
|
String id = UUID.randomUUID().toString();
|
||||||
|
DshSdkTurn turn = new DshSdkTurn(id, "health-prompt");
|
||||||
|
sdk.send("session/prompt", "health-prompt", Map.of("sessionId", id,
|
||||||
|
"contentBlocks", List.of(Map.of("type", "text", "text", "Reply with exactly OK. Do not use tools."))));
|
||||||
|
long deadline = System.nanoTime() + Duration.ofSeconds(60).toNanos();
|
||||||
|
while (!turn.complete()) {
|
||||||
|
if (System.nanoTime() >= deadline) throw new IOException("DSH_TASK_TIMEOUT");
|
||||||
|
turn.accept(sdk.next(Duration.ofNanos(deadline - System.nanoTime())));
|
||||||
|
}
|
||||||
|
if (turn.terminalType() != RuntimeEventType.COMPLETED || !"OK".equals(turn.answer().trim()))
|
||||||
|
throw new IOException("DSH_TASK_INCOMPLETE: " + turn.reason());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Map.of("success", true, "kind", task ? "task" : "handshake",
|
||||||
|
"message", task ? "SDK model task completed" : "SDK initialize handshake completed",
|
||||||
|
"checkedAt", Instant.now().toString(), "durationMs", System.currentTimeMillis() - started,
|
||||||
|
"versionStatus", "UNVERIFIED_VERSION");
|
||||||
|
} catch (Exception error) {
|
||||||
|
return Map.of("success", false, "kind", task ? "task" : "handshake",
|
||||||
|
"message", DshSdkProcess.redact(error.getMessage(), configuration.apiKey()),
|
||||||
|
"checkedAt", Instant.now().toString(), "durationMs", System.currentTimeMillis() - started);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static Path resolveWorkingDirectory(RuntimeSession session, DshRuntimeConfiguration configuration) {
|
static Path resolveWorkingDirectory(RuntimeSession session, DshRuntimeConfiguration configuration) {
|
||||||
if (session != null && session.workingDirectory() != null) {
|
if (session != null && session.workingDirectory() != null) {
|
||||||
return session.workingDirectory().toAbsolutePath().normalize();
|
return session.workingDirectory().toAbsolutePath().normalize();
|
||||||
@ -384,7 +403,7 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static void cancelProcess(Process process) {
|
static void cancelProcess(Process process) {
|
||||||
if (process == null || !process.isAlive()) return;
|
if (process == null) return;
|
||||||
|
|
||||||
// DSH tools can spawn commands such as `sleep` that inherit the
|
// DSH tools can spawn commands such as `sleep` that inherit the
|
||||||
// JSON-RPC process' stdout pipe. Close the pipes and terminate the
|
// JSON-RPC process' stdout pipe. Close the pipes and terminate the
|
||||||
@ -403,6 +422,8 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
|||||||
closeQuietly(process.getOutputStream());
|
closeQuietly(process.getOutputStream());
|
||||||
process.destroy();
|
process.destroy();
|
||||||
if (process.isAlive()) process.destroyForcibly();
|
if (process.isAlive()) process.destroyForcibly();
|
||||||
|
try { process.waitFor(1, TimeUnit.SECONDS); }
|
||||||
|
catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void cancelProcessHandle(ProcessHandle process) {
|
private static void cancelProcessHandle(ProcessHandle process) {
|
||||||
@ -411,7 +432,7 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
|||||||
if (process.isAlive()) process.destroyForcibly();
|
if (process.isAlive()) process.destroyForcibly();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void closeQuietly(java.io.Closeable stream) {
|
private static void closeQuietly(Closeable stream) {
|
||||||
if (stream == null) return;
|
if (stream == null) return;
|
||||||
try {
|
try {
|
||||||
stream.close();
|
stream.close();
|
||||||
@ -420,51 +441,6 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void logProcessStderr(Process process) {
|
|
||||||
try (BufferedReader errors = new BufferedReader(new InputStreamReader(
|
|
||||||
process.getErrorStream(), StandardCharsets.UTF_8))) {
|
|
||||||
String line;
|
|
||||||
while ((line = errors.readLine()) != null) {
|
|
||||||
log.warn("[DSH] {}", line);
|
|
||||||
}
|
|
||||||
} catch (IOException error) {
|
|
||||||
log.debug("[DSH] stderr reader closed: {}", error.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static List<String> commandLine(String commandLine) {
|
|
||||||
List<String> result = new ArrayList<>();
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
static Map<String, String> childEnvironment(Map<String, String> inherited,
|
static Map<String, String> childEnvironment(Map<String, String> inherited,
|
||||||
RuntimeSession session,
|
RuntimeSession session,
|
||||||
DshRuntimeConfiguration configuration,
|
DshRuntimeConfiguration configuration,
|
||||||
@ -480,14 +456,21 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
|||||||
copyIfPresent(inherited, environment, "WINDIR");
|
copyIfPresent(inherited, environment, "WINDIR");
|
||||||
|
|
||||||
environment.put("DSH_CWD", session.workingDirectory().toString());
|
environment.put("DSH_CWD", session.workingDirectory().toString());
|
||||||
putIfPresent(environment, "DSH_CORDIS_CONFIG", configuration.cordisConfigPath());
|
environment.put("DSH_HOME", Path.of(configuration.homeRoot()).toAbsolutePath().normalize()
|
||||||
|
.resolve(UUID.nameUUIDFromBytes(String.valueOf(session.workspaceId()).getBytes(StandardCharsets.UTF_8)).toString())
|
||||||
|
.resolve(UUID.nameUUIDFromBytes(String.valueOf(session.agentId()).getBytes(StandardCharsets.UTF_8)).toString()).toString());
|
||||||
putIfPresent(environment, "DEEPSEEK_API_KEY",
|
putIfPresent(environment, "DEEPSEEK_API_KEY",
|
||||||
firstNonBlank(configuration.apiKey(), provider == null ? null : provider.getApiKey()));
|
firstNonBlank(configuration.apiKey(), provider == null ? null : provider.getApiKey()));
|
||||||
putIfPresent(environment, "DEEPSEEK_BASE_URL",
|
putIfPresent(environment, "DEEPSEEK_BASE_URL",
|
||||||
firstNonBlank(configuration.baseUrl(), provider == null ? null : provider.getBaseUrl()));
|
normalizeBaseUrl(firstNonBlank(configuration.baseUrl(), provider == null ? null : provider.getBaseUrl())));
|
||||||
return environment;
|
return environment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String normalizeBaseUrl(String url) {
|
||||||
|
if (url == null) return null;
|
||||||
|
return url.matches("https://api\\.deepseek\\.com(?:/v1)?/?") ? "https://api.deepseek.com/anthropic" : url;
|
||||||
|
}
|
||||||
|
|
||||||
private static void copyIfPresent(Map<String, String> source, Map<String, String> target, String key) {
|
private static void copyIfPresent(Map<String, String> source, Map<String, String> target, String key) {
|
||||||
if (source == null) return;
|
if (source == null) return;
|
||||||
putIfPresent(target, key, source.get(key));
|
putIfPresent(target, key, source.get(key));
|
||||||
@ -552,6 +535,15 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
|||||||
RuntimeEvent mapEvent(String sessionId, long sequence, JsonNode event) {
|
RuntimeEvent mapEvent(String sessionId, long sequence, JsonNode event) {
|
||||||
String type = event.path("type").asText("");
|
String type = event.path("type").asText("");
|
||||||
JsonNode data = event.path("data");
|
JsonNode data = event.path("data");
|
||||||
|
if ("assistant/message".equals(type)) {
|
||||||
|
JsonNode content = data.path("message").path("content");
|
||||||
|
if (!content.isArray()) throw new IllegalArgumentException("DSH_PROTOCOL_ERROR: malformed assistant content");
|
||||||
|
StringBuilder text = new StringBuilder();
|
||||||
|
for (JsonNode block : content) {
|
||||||
|
if ("text".equals(block.path("type").asText())) text.append(block.path("text").asText(""));
|
||||||
|
}
|
||||||
|
return RuntimeEvent.of(sessionId, sequence, RuntimeEventType.ASSISTANT_DELTA, text.toString(), Map.of());
|
||||||
|
}
|
||||||
if ("assistant/chunk".equals(type)) {
|
if ("assistant/chunk".equals(type)) {
|
||||||
JsonNode chunk = data.has("chunk") ? data.path("chunk") : data;
|
JsonNode chunk = data.has("chunk") ? data.path("chunk") : data;
|
||||||
if ("usage".equals(chunk.path("type").asText())) {
|
if ("usage".equals(chunk.path("type").asText())) {
|
||||||
@ -591,32 +583,20 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
|||||||
}
|
}
|
||||||
if (type.contains("tool") && (type.contains("start") || type.contains("call"))) {
|
if (type.contains("tool") && (type.contains("start") || type.contains("call"))) {
|
||||||
return RuntimeEvent.of(sessionId, sequence, RuntimeEventType.TOOL_STARTED, null,
|
return RuntimeEvent.of(sessionId, sequence, RuntimeEventType.TOOL_STARTED, null,
|
||||||
Map.of("toolName", data.path("toolName").asText("dsh-tool")));
|
Map.of("toolName", data.path("name").asText("dsh-tool"), "callId", data.path("callId").asText("")));
|
||||||
}
|
}
|
||||||
if (type.contains("tool") && (type.contains("end") || type.contains("result"))) {
|
if (type.contains("tool") && (type.contains("end") || type.contains("result"))) {
|
||||||
return RuntimeEvent.of(sessionId, sequence, RuntimeEventType.TOOL_FINISHED, null, Map.of());
|
return RuntimeEvent.of(sessionId, sequence, RuntimeEventType.TOOL_FINISHED, null, Map.of("callId", data.path("message").path("toolCallId").asText(data.path("callId").asText(""))));
|
||||||
}
|
}
|
||||||
if ("turn/end".equals(type)) {
|
if ("turn/end".equals(type)) {
|
||||||
String kind = data.path("reason").path("kind").asText("");
|
String kind = data.path("reason").path("kind").asText("");
|
||||||
if ("error".equals(kind)) {
|
return RuntimeEvent.terminal(sessionId, sequence,
|
||||||
return RuntimeEvent.terminal(sessionId, sequence, RuntimeEventType.FAILED,
|
"completed".equals(kind) ? RuntimeEventType.COMPLETED : RuntimeEventType.FAILED,
|
||||||
Map.of("error", data.path("reason").path("error").path("message").asText("DSH turn failed")));
|
"completed".equals(kind) ? Map.of() : Map.of("code", "DSH_INCOMPLETE_TURN", "error", "DSH turn ended: " + kind));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return null;
|
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) {
|
private String firstText(JsonNode primary, JsonNode fallback) {
|
||||||
String text = primary.path("text").asText(null);
|
String text = primary.path("text").asText(null);
|
||||||
if (text != null) return text;
|
if (text != null) return text;
|
||||||
@ -627,58 +607,9 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
|||||||
return fallback.path("delta").path("text").asText(null);
|
return fallback.path("delta").path("text").asText(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void logChunkMetadata(JsonNode event) {
|
|
||||||
if (!"assistant/chunk".equals(event.path("type").asText())) return;
|
|
||||||
JsonNode data = event.path("data");
|
|
||||||
JsonNode chunk = data.has("chunk") ? data.path("chunk") : data;
|
|
||||||
log.debug("[DSH] assistant chunk: type={}, fields={}, dataFields={}, textPresent={}, textLength={}",
|
|
||||||
chunk.path("type").asText("<missing>"),
|
|
||||||
chunk.fieldNames().hasNext(), data.fieldNames().hasNext(),
|
|
||||||
chunk.has("text"), chunk.path("text").isTextual() ? chunk.path("text").textValue().length() : 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void logTerminalReason(JsonNode event) {
|
|
||||||
String type = event.path("type").asText("");
|
|
||||||
if (!"assistant/chunk".equals(type) && !"turn/end".equals(type)) return;
|
|
||||||
JsonNode reason = "assistant/chunk".equals(type)
|
|
||||||
? event.path("data").path("chunk").path("reason")
|
|
||||||
: event.path("data").path("reason");
|
|
||||||
if (reason.isMissingNode() || reason.isNull()) return;
|
|
||||||
JsonNode failure = reason.path("failure").isMissingNode()
|
|
||||||
? reason.path("error") : reason.path("failure");
|
|
||||||
log.warn("[DSH] terminal reason: eventType={}, kind={}, code={}, message={}",
|
|
||||||
type,
|
|
||||||
reason.path("kind").asText("<missing>"),
|
|
||||||
failure.path("code").asText("<none>"),
|
|
||||||
failure.path("message").asText("<none>"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void awaitResponse(BufferedReader reader, String id) throws IOException {
|
|
||||||
String line;
|
|
||||||
while ((line = reader.readLine()) != null) {
|
|
||||||
JsonNode payload = objectMapper.readTree(line);
|
|
||||||
if (payload != null && id.equals(payload.path("id").asText(null))) {
|
|
||||||
if (payload.has("error")) {
|
|
||||||
throw new IllegalStateException(payload.path("error").path("message").asText("DSH JSON-RPC error"));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new IOException("DSH runtime closed while waiting for " + id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, Object> request(String method, String id, Map<String, Object> params) {
|
|
||||||
return Map.of("jsonrpc", "2.0", "id", id, "method", method, "params", params);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, Object> errorResponse(JsonNode id, int code, String message) {
|
private Map<String, Object> errorResponse(JsonNode id, int code, String message) {
|
||||||
return Map.of("jsonrpc", "2.0", "id", objectMapper.convertValue(id, Object.class),
|
return Map.of("jsonrpc", "2.0", "id", objectMapper.convertValue(id, Object.class),
|
||||||
"error", Map.of("code", code, "message", message));
|
"error", Map.of("code", code, "message", message));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void send(BufferedWriter writer, Map<String, Object> payload) throws IOException {
|
|
||||||
writer.write(objectMapper.writeValueAsString(payload));
|
|
||||||
writer.newLine();
|
|
||||||
writer.flush();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,138 @@
|
|||||||
|
package vip.mate.agent.runtime.dsh;
|
||||||
|
|
||||||
|
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import com.fasterxml.jackson.databind.*;
|
||||||
|
import java.io.*;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.*;
|
||||||
|
|
||||||
|
/** Bounded line-delimited SDK transport. Synthetic tests do not certify a DSH release. */
|
||||||
|
public final class DshSdkProcess implements AutoCloseable {
|
||||||
|
public static final int MAX_FRAME_BYTES = 16 * 1024 * 1024;
|
||||||
|
private static final int MAX_PENDING_BYTES = 16 * 1024 * 1024;
|
||||||
|
private final Process process;
|
||||||
|
private final ObjectMapper mapper;
|
||||||
|
private final BufferedWriter writer;
|
||||||
|
private final BlockingQueue<Object> frames = new ArrayBlockingQueue<>(4096);
|
||||||
|
private final AtomicInteger pendingBytes = new AtomicInteger();
|
||||||
|
private final String secret;
|
||||||
|
private final StringBuilder stderr = new StringBuilder();
|
||||||
|
private volatile boolean closed;
|
||||||
|
private volatile boolean initialized;
|
||||||
|
private volatile IOException failure;
|
||||||
|
|
||||||
|
public DshSdkProcess(Process process, ObjectMapper mapper, String secret) {
|
||||||
|
this.process = process; this.mapper = mapper; this.secret = secret;
|
||||||
|
writer = new BufferedWriter(new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8));
|
||||||
|
startReader("dsh-sdk-stdout", this::readFrames);
|
||||||
|
startReader("dsh-sdk-stderr", this::readErrors);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void startReader(String name, Runnable action) {
|
||||||
|
Thread thread = new Thread(action, name);
|
||||||
|
thread.setDaemon(true);
|
||||||
|
thread.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void send(String method, String id, Map<String, Object> params) throws IOException {
|
||||||
|
send(Map.of("jsonrpc", "2.0", "method", method, "id", id, "params", params));
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void send(Map<String, Object> message) throws IOException {
|
||||||
|
writer.write(mapper.writeValueAsString(message)); writer.newLine(); writer.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
public JsonNode initialize(Map<String, Object> params, Duration timeout) throws Exception {
|
||||||
|
String id = "initialize-" + UUID.randomUUID();
|
||||||
|
send("initialize", id, params);
|
||||||
|
long deadline = System.nanoTime() + bounded(timeout).toNanos();
|
||||||
|
for (;;) {
|
||||||
|
if (System.nanoTime() >= deadline) throw new IOException("DSH_TIMEOUT");
|
||||||
|
JsonNode frame = next(Duration.ofNanos(Math.max(1, deadline - System.nanoTime())));
|
||||||
|
if (!frame.has("id")) continue;
|
||||||
|
if (!id.equals(frame.path("id").asText())) throw new IOException("DSH_HANDSHAKE_ID_MISMATCH");
|
||||||
|
if (frame.has("error")) throw new IOException("DSH_INITIALIZE_REJECTED: " + redact(frame.path("error").path("message").asText("runtime error"), secret));
|
||||||
|
if (!"deepseek-harness-sdk-runtime".equals(frame.path("result").path("serverInfo").path("name").asText()))
|
||||||
|
throw new IOException("DSH_HANDSHAKE_IDENTITY_MISMATCH");
|
||||||
|
initialized = true;
|
||||||
|
return frame.path("result");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public JsonNode next(Duration timeout) throws Exception {
|
||||||
|
if (failure != null) throw failure;
|
||||||
|
Object value = frames.poll(Math.max(1, timeout.toMillis()), TimeUnit.MILLISECONDS);
|
||||||
|
if (failure != null) throw failure;
|
||||||
|
if (value == null) throw new IOException("DSH_TIMEOUT");
|
||||||
|
if (value instanceof IOException error) throw error;
|
||||||
|
byte[] bytes = (byte[]) value;
|
||||||
|
pendingBytes.addAndGet(-bytes.length);
|
||||||
|
String line = new String(bytes, StandardCharsets.UTF_8);
|
||||||
|
if (line.stripLeading().startsWith("dsh web:")) throw new IOException("DSH_WRONG_ENTRYPOINT: Web profile banner received; select sdk");
|
||||||
|
JsonNode node;
|
||||||
|
try { node = mapper.readTree(line); } catch (Exception error) { throw new IOException("DSH_INVALID_FRAME: expected SDK JSON-RPC"); }
|
||||||
|
if (node == null || !node.isObject() || !"2.0".equals(node.path("jsonrpc").asText())) throw new IOException("DSH_INVALID_FRAME: expected JSON-RPC 2.0");
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void readFrames() {
|
||||||
|
try (InputStream input = process.getInputStream()) {
|
||||||
|
ByteArrayOutputStream line = new ByteArrayOutputStream();
|
||||||
|
byte[] buffer = new byte[8192]; int count;
|
||||||
|
while (!closed && (count = input.read(buffer)) >= 0) {
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
if (buffer[i] == '\n') {
|
||||||
|
byte[] frame = line.toByteArray(); line.reset();
|
||||||
|
if (pendingBytes.addAndGet(frame.length) > MAX_PENDING_BYTES || !frames.offer(frame))
|
||||||
|
throw new IOException("DSH_NOTIFICATION_OVERFLOW");
|
||||||
|
} else {
|
||||||
|
if (line.size() >= MAX_FRAME_BYTES) throw new IOException("DSH_FRAME_TOO_LARGE");
|
||||||
|
line.write(buffer[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!closed) frames.offer(new IOException("DSH_EOF: runtime closed before completion"));
|
||||||
|
} catch (IOException error) {
|
||||||
|
if (!closed) { failure = error; frames.offer(error); DshRuntimeService.cancelProcess(process); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void readErrors() {
|
||||||
|
try (Reader reader = new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8)) {
|
||||||
|
char[] buffer = new char[2048]; int count;
|
||||||
|
while ((count = reader.read(buffer)) >= 0) synchronized (stderr) {
|
||||||
|
stderr.append(buffer, 0, count);
|
||||||
|
if (stderr.length() > 65536) stderr.delete(0, stderr.length() - 65536);
|
||||||
|
}
|
||||||
|
} catch (IOException ignored) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
public String diagnostics() { synchronized (stderr) { return redact(stderr.toString(), secret); } }
|
||||||
|
|
||||||
|
public static String redact(String text, String secret) {
|
||||||
|
if (text == null) return "";
|
||||||
|
String safe = secret == null || secret.isBlank() ? text : text.replace(secret, "[REDACTED]");
|
||||||
|
return safe.replaceAll("(?i)(authorization[\"']?\\s*[:=]\\s*[\"']?)(?:Bearer|Basic)\\s+[^\\s,\"'}]+", "$1[REDACTED]")
|
||||||
|
.replaceAll("(?i)([?&](?:token|key|api_key|access_token)=)[^\\s&#]+", "$1[REDACTED]")
|
||||||
|
.replaceAll("(?i)((?:authorization|api[_-]?key|password|secret|token)[\\\"']?\\s*[:=]\\s*[\\\"']?)[^\\s,\\\"'}]+", "$1[REDACTED]");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Duration bounded(Duration timeout) {
|
||||||
|
if (timeout == null || timeout.isNegative() || timeout.isZero()) return Duration.ofSeconds(30);
|
||||||
|
return timeout.compareTo(Duration.ofMinutes(2)) > 0 ? Duration.ofMinutes(2) : timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void close() {
|
||||||
|
if (closed) return;
|
||||||
|
try {
|
||||||
|
if (initialized && process.isAlive()) {
|
||||||
|
send("shutdown", "shutdown-" + UUID.randomUUID(), Map.of());
|
||||||
|
process.waitFor(5, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) { }
|
||||||
|
finally { closed = true; DshRuntimeService.cancelProcess(process); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,117 @@
|
|||||||
|
package vip.mate.agent.runtime.dsh;
|
||||||
|
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import vip.mate.agent.runtime.contract.RuntimeEventType;
|
||||||
|
import java.util.*;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/** Correlates one prompt's durable inbox receipt with its root-session activity. */
|
||||||
|
public final class DshSdkTurn {
|
||||||
|
// Session vocabulary pinned to the two adapted SDK prereleases.
|
||||||
|
private static final Set<String> KNOWN_EVENTS = Set.of(
|
||||||
|
"agent-preset/selected", "agent/inbox/spliced", "approval/asked", "approval/decided",
|
||||||
|
"approval/policy", "assistant/attempt", "assistant/message", "command/done",
|
||||||
|
"command/run", "compaction/end", "compaction/prune", "compaction/start",
|
||||||
|
"compaction/summary", "deliverables/presented", "developer/message", "feedback/message-delete",
|
||||||
|
"feedback/message-put", "feedback/record", "goal/change", "hook/invoked",
|
||||||
|
"hook/result", "image/offload", "llm/retry", "llm/retry-started",
|
||||||
|
"model/selection", "permission/preset", "plan/mode", "request/context",
|
||||||
|
"request/header", "sandbox/mode", "schedule/change", "session-log-deepseek/delivery-accepted",
|
||||||
|
"session/end-seed", "session/title", "session/title-llm-request", "step/end",
|
||||||
|
"step/start", "subagent/catalog", "subagent/descriptor", "subagent/model-selection-policy",
|
||||||
|
"system/message", "team/member", "team/message/delivered", "team/message/queued",
|
||||||
|
"team/task", "todo/write", "tool-workflow/agent-end", "tool-workflow/agent-start",
|
||||||
|
"tool-workflow/run-end", "tool-workflow/run-start", "tool/call", "tool/ptc-dispatch",
|
||||||
|
"tool/ptc-dispatch-start", "tool/result", "turn/end", "turn/start",
|
||||||
|
"user/message", "web/deepseek-search-llm-request", "workspace/changes");
|
||||||
|
private final String sessionId;
|
||||||
|
private final String promptId;
|
||||||
|
private final List<JsonNode> pending = new ArrayList<>();
|
||||||
|
private final Set<Long> sequences = new HashSet<>();
|
||||||
|
private int pendingBytes;
|
||||||
|
private String messageId;
|
||||||
|
private boolean received;
|
||||||
|
private boolean complete;
|
||||||
|
private String reason;
|
||||||
|
private String answer = "";
|
||||||
|
private long inputTokens;
|
||||||
|
private long outputTokens;
|
||||||
|
|
||||||
|
public DshSdkTurn(String sessionId, String promptId) { this.sessionId = sessionId; this.promptId = promptId; }
|
||||||
|
|
||||||
|
/** Returns newly admitted root events; caller projects tools and settled reasoning. */
|
||||||
|
public List<JsonNode> accept(JsonNode frame) {
|
||||||
|
if (complete) return List.of();
|
||||||
|
if (frame.has("id") && promptId.equals(frame.path("id").asText())) {
|
||||||
|
if (frame.has("error")) throw new IllegalStateException("DSH_PROMPT_REJECTED");
|
||||||
|
messageId = frame.path("result").path("messageId").asText("");
|
||||||
|
if (messageId.isBlank()) throw new IllegalStateException("DSH_PROMPT_RECEIPT_INVALID");
|
||||||
|
List<JsonNode> events = new ArrayList<>();
|
||||||
|
for (JsonNode notification : pending) events.addAll(admit(notification));
|
||||||
|
pending.clear(); pendingBytes = 0;
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
if (!frame.has("method")) return List.of();
|
||||||
|
if (messageId == null) {
|
||||||
|
int size = frame.toString().getBytes(StandardCharsets.UTF_8).length;
|
||||||
|
if (pending.size() >= 4096 || (long) pendingBytes + size > 16 * 1024 * 1024)
|
||||||
|
throw new IllegalStateException("DSH_NOTIFICATION_OVERFLOW");
|
||||||
|
pending.add(frame); pendingBytes += size;
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return admit(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<JsonNode> admit(JsonNode frame) {
|
||||||
|
JsonNode params = frame.path("params");
|
||||||
|
if (!sessionId.equals(params.path("sessionId").asText())) return List.of();
|
||||||
|
String method = frame.path("method").asText();
|
||||||
|
JsonNode event = params.path("event");
|
||||||
|
if (!received) {
|
||||||
|
if (!"session.event".equals(method) || !"agent/inbox/spliced".equals(event.path("type").asText())) return List.of();
|
||||||
|
for (JsonNode inserted : event.path("data").path("inserted"))
|
||||||
|
if (messageId.equals(inserted.path("id").asText())) received = true;
|
||||||
|
if (!received) return List.of();
|
||||||
|
}
|
||||||
|
if ("session.status".equals(method) && "idle".equals(params.path("status").asText())) {
|
||||||
|
complete = true;
|
||||||
|
if (reason == null) reason = "missing-turn-end";
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
if (!"session.event".equals(method)) return List.of();
|
||||||
|
if (!event.path("seq").canConvertToLong() || !event.path("seq").isIntegralNumber())
|
||||||
|
throw new IllegalStateException("DSH_EVENT_SEQUENCE_INVALID");
|
||||||
|
if (!sequences.add(event.path("seq").asLong())) return List.of();
|
||||||
|
String type = event.path("type").asText();
|
||||||
|
if (!KNOWN_EVENTS.contains(type) && !event.path("ignorable").asBoolean(false))
|
||||||
|
throw new IllegalStateException("DSH_UNKNOWN_REQUIRED_EVENT: " + type);
|
||||||
|
JsonNode data = event.path("data");
|
||||||
|
if ("assistant/message".equals(type)) {
|
||||||
|
JsonNode content = data.path("message").path("content");
|
||||||
|
if (!content.isArray()) throw new IllegalStateException("DSH_ASSISTANT_CONTENT_INVALID");
|
||||||
|
StringBuilder text = new StringBuilder();
|
||||||
|
for (JsonNode block : content) {
|
||||||
|
if (!block.path("type").isTextual()) throw new IllegalStateException("DSH_ASSISTANT_CONTENT_INVALID");
|
||||||
|
if ("text".equals(block.path("type").asText())) text.append(block.path("text").asText(""));
|
||||||
|
}
|
||||||
|
answer = text.toString();
|
||||||
|
inputTokens += Math.max(0, data.path("usage").path("inputTokens").asLong())
|
||||||
|
+ Math.max(0, data.path("usage").path("cacheReadTokens").asLong())
|
||||||
|
+ Math.max(0, data.path("usage").path("cacheWriteTokens").asLong());
|
||||||
|
outputTokens += Math.max(0, data.path("usage").path("outputTokens").asLong());
|
||||||
|
if (data.path("interrupted").asBoolean(false)) reason = "interrupted";
|
||||||
|
} else if ("turn/end".equals(type)) {
|
||||||
|
String terminal = data.path("reason").path("kind").asText("unknown");
|
||||||
|
if (!"interrupted".equals(reason)) reason = terminal;
|
||||||
|
}
|
||||||
|
return List.of(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean complete() { return complete; }
|
||||||
|
public RuntimeEventType terminalType() { return "completed".equals(reason) ? RuntimeEventType.COMPLETED : RuntimeEventType.FAILED; }
|
||||||
|
public String reason() { return reason == null ? "missing-turn-end" : reason; }
|
||||||
|
public String answer() { return answer; }
|
||||||
|
public long inputTokens() { return inputTokens; }
|
||||||
|
public long outputTokens() { return outputTokens; }
|
||||||
|
}
|
||||||
@ -0,0 +1,333 @@
|
|||||||
|
package vip.mate.agent.runtime.dsh.management;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import vip.mate.system.service.SettingCrypto;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.channels.FileChannel;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.LinkOption;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.nio.file.StandardOpenOption;
|
||||||
|
import java.nio.file.attribute.PosixFilePermissions;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.locks.ReentrantLock;
|
||||||
|
|
||||||
|
/** Durable generation selection and process-shared admission for local runtimes. */
|
||||||
|
@Component
|
||||||
|
public class DshGenerationStore {
|
||||||
|
private static final ConcurrentHashMap<Path, ReentrantLock> LOCKS = new ConcurrentHashMap<>();
|
||||||
|
private static final TypeReference<Map<String, String>> VALUES = new TypeReference<>() {};
|
||||||
|
private final Path root;
|
||||||
|
private final ObjectMapper mapper;
|
||||||
|
private final SettingCrypto crypto;
|
||||||
|
|
||||||
|
public DshGenerationStore(
|
||||||
|
@Value("${mateclaw.agent.runtime.dsh.install-root:${user.home}/.mateclaw/runtimes/deepseek-harness}") String root,
|
||||||
|
ObjectMapper mapper, SettingCrypto crypto) {
|
||||||
|
this.root = Path.of(root).toAbsolutePath().normalize();
|
||||||
|
this.mapper = mapper;
|
||||||
|
this.crypto = crypto;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Path root() { return root; }
|
||||||
|
|
||||||
|
public String revision() { return locked(() -> pointer().getOrDefault("revision", "0")); }
|
||||||
|
public String activeGeneration() { return locked(() -> pointer().getOrDefault("generation", "")); }
|
||||||
|
public Map<String, String> activeValues() {
|
||||||
|
return locked(() -> readGeneration(pointer().getOrDefault("generation", "")));
|
||||||
|
}
|
||||||
|
public Map<String, String> generationValues(String generation) {
|
||||||
|
return locked(() -> readGeneration(generation));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps configuration changes in the same atomic domain as activation. */
|
||||||
|
public void updateActiveValues(Map<String, String> values) {
|
||||||
|
updateActiveValues(values, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A resolved configuration snapshot may only replace the revision it read. */
|
||||||
|
public void updateActiveValues(Map<String, String> values, String expectedRevision) {
|
||||||
|
locked(() -> {
|
||||||
|
requireWritable();
|
||||||
|
Map<String, String> active = pointer();
|
||||||
|
if (expectedRevision != null && !active.getOrDefault("revision", "0").equals(expectedRevision))
|
||||||
|
throw new IllegalStateException("dsh.stale_configuration");
|
||||||
|
Map<String, String> merged = readGeneration(active.getOrDefault("generation", ""));
|
||||||
|
merged.putAll(values);
|
||||||
|
writeGeneration("config-" + UUID.randomUUID(), merged);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Lease acquireLease(String workspaceId, String agentId) {
|
||||||
|
return locked(() -> {
|
||||||
|
requireWritable();
|
||||||
|
pruneLeases();
|
||||||
|
String key = workspaceId + ":" + agentId;
|
||||||
|
try (var paths = Files.list(directory("leases"))) {
|
||||||
|
for (Path path : paths.toList()) {
|
||||||
|
if (key.equals(read(path).get("home"))) throw new IllegalStateException("dsh.home_busy");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Path file = directory("leases").resolve(UUID.randomUUID() + ".json");
|
||||||
|
Map<String, String> lease = owner();
|
||||||
|
lease.put("home", key);
|
||||||
|
write(file, lease);
|
||||||
|
return new Lease(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void beginUpgrade(String operationId, String expectedRevision) {
|
||||||
|
safeId(operationId);
|
||||||
|
locked(() -> {
|
||||||
|
requireWritable();
|
||||||
|
if (!pointer().getOrDefault("revision", "0").equals(expectedRevision)) {
|
||||||
|
throw new IllegalStateException("dsh.stale_configuration");
|
||||||
|
}
|
||||||
|
Map<String, String> gate = owner();
|
||||||
|
gate.put("operation", operationId);
|
||||||
|
write(root.resolve("gate.json"), gate);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean awaitDrained(Duration timeout) throws InterruptedException {
|
||||||
|
long deadline = System.nanoTime() + timeout.toNanos();
|
||||||
|
do {
|
||||||
|
if (locked(() -> { pruneLeases(); try (var files = Files.list(directory("leases"))) { return files.findAny().isEmpty(); } })) return true;
|
||||||
|
if (System.nanoTime() >= deadline) return false;
|
||||||
|
Thread.sleep(Math.min(50, Math.max(1, timeout.toMillis())));
|
||||||
|
} while (true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void activate(String operationId, String generation, Map<String, String> values) {
|
||||||
|
safeId(generation);
|
||||||
|
locked(() -> {
|
||||||
|
requireGate(operationId);
|
||||||
|
requireDrained();
|
||||||
|
writeGeneration(generation, values);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Saves the pre-upgrade configuration without changing the committed active pointer. */
|
||||||
|
public void saveSnapshot(String operationId, String generation, Map<String, String> values) {
|
||||||
|
safeId(generation);
|
||||||
|
locked(() -> {
|
||||||
|
requireGate(operationId);
|
||||||
|
requireDrained();
|
||||||
|
writeGenerationSnapshot(generation, values);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void restore(String operationId, String generation) {
|
||||||
|
safeId(generation);
|
||||||
|
locked(() -> {
|
||||||
|
requireGate(operationId);
|
||||||
|
requireDrained();
|
||||||
|
if (!Files.isRegularFile(directory("generations").resolve(generation + ".json"))) throw new IllegalArgumentException("dsh.generation_missing");
|
||||||
|
write(root.resolve("active.json"), Map.of("generation", generation, "revision", UUID.randomUUID().toString()));
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void finishUpgrade(String operationId) {
|
||||||
|
locked(() -> {
|
||||||
|
Map<String, String> gate = read(root.resolve("gate.json"));
|
||||||
|
if (operationId.equals(gate.get("operation"))) {
|
||||||
|
Map<String, String> op = read(directory("operations").resolve(operationId + ".json"));
|
||||||
|
if (op.isEmpty() || isTerminal(op.get("state"))) Files.deleteIfExists(root.resolve("gate.json"));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean createOperation(String id, Map<String, String> operation) {
|
||||||
|
safeId(id);
|
||||||
|
return locked(() -> {
|
||||||
|
Path file = directory("operations").resolve(id + ".json");
|
||||||
|
if (Files.exists(file)) return false;
|
||||||
|
write(file, operation);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void saveOperation(String id, Map<String, String> operation) {
|
||||||
|
safeId(id);
|
||||||
|
locked(() -> { write(directory("operations").resolve(id + ".json"), operation); return null; });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reconciles interrupted operations from the durable activation pointer. */
|
||||||
|
public Map<String, String> operation(String id) {
|
||||||
|
safeId(id);
|
||||||
|
return locked(() -> {
|
||||||
|
Map<String, String> op = read(directory("operations").resolve(id + ".json"));
|
||||||
|
if (op.isEmpty()) throw new IllegalArgumentException("dsh.operation_missing");
|
||||||
|
boolean committedPointer = op.getOrDefault("candidateGeneration", "!").equals(pointer().get("generation"));
|
||||||
|
if ((!isOwnerAlive(op) || committedPointer) && !isTerminal(op.get("state"))) {
|
||||||
|
boolean committed = op.getOrDefault("candidateGeneration", "!").equals(pointer().get("generation"));
|
||||||
|
op.put("state", committed ? "COMPLETED" : "INTERRUPTED");
|
||||||
|
op.put("message", committed ? "Activation committed before restart" : "Upgrade interrupted; active generation retained");
|
||||||
|
write(directory("operations").resolve(id + ".json"), op);
|
||||||
|
}
|
||||||
|
return op;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Map<String, String> owner() {
|
||||||
|
Map<String, String> values = new HashMap<>();
|
||||||
|
values.put("pid", Long.toString(ProcessHandle.current().pid()));
|
||||||
|
values.put("started", ProcessHandle.current().info().startInstant().map(Object::toString).orElse(""));
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isTerminal(String state) {
|
||||||
|
return "COMPLETED".equals(state) || "FAILED".equals(state) || "INTERRUPTED".equals(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeGeneration(String id, Map<String, String> values) throws IOException {
|
||||||
|
writeGenerationSnapshot(id, values);
|
||||||
|
write(root.resolve("active.json"), Map.of("generation", id, "revision", UUID.randomUUID().toString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeGenerationSnapshot(String id, Map<String, String> values) throws IOException {
|
||||||
|
safeId(id);
|
||||||
|
Map<String, String> stored = new HashMap<>(values);
|
||||||
|
stored.replaceAll((key, value) -> value == null ? "" : value);
|
||||||
|
stored.computeIfPresent("dsh.api_key", (key, value) -> crypto.encrypt(value));
|
||||||
|
write(directory("generations").resolve(id + ".json"), stored);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> readGeneration(String id) throws IOException {
|
||||||
|
if (id.isBlank()) return new HashMap<>();
|
||||||
|
safeId(id);
|
||||||
|
Path path = directory("generations").resolve(id + ".json");
|
||||||
|
if (!Files.isRegularFile(path)) throw new IllegalStateException("dsh.active_generation_missing");
|
||||||
|
Map<String, String> values = read(path);
|
||||||
|
values.computeIfPresent("dsh.api_key", (key, value) -> crypto.decrypt(value));
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireGate(String id) throws IOException {
|
||||||
|
if (!id.equals(read(root.resolve("gate.json")).get("operation"))) throw new IllegalStateException("dsh.upgrade_ownership_lost");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireDrained() throws IOException {
|
||||||
|
pruneLeases();
|
||||||
|
try (var files = Files.list(directory("leases"))) {
|
||||||
|
if (files.findAny().isPresent()) throw new IllegalStateException("dsh.active_turns");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireWritable() throws IOException {
|
||||||
|
Map<String, String> gate = read(root.resolve("gate.json"));
|
||||||
|
if (gate.isEmpty()) return;
|
||||||
|
String operation = gate.get("operation");
|
||||||
|
Map<String, String> journalState = operation == null ? Map.of() : read(directory("operations").resolve(operation + ".json"));
|
||||||
|
boolean committed = journalState.getOrDefault("candidateGeneration", "!").equals(pointer().get("generation"));
|
||||||
|
if (isOwnerAlive(gate) && !committed && !isTerminal(journalState.get("state"))) {
|
||||||
|
throw new IllegalStateException("dsh.upgrade_in_progress");
|
||||||
|
}
|
||||||
|
if (operation != null) {
|
||||||
|
Path journal = directory("operations").resolve(operation + ".json");
|
||||||
|
Map<String, String> op = read(journal);
|
||||||
|
if (!op.isEmpty() && !isTerminal(op.get("state"))) {
|
||||||
|
boolean wasCommitted = op.getOrDefault("candidateGeneration", "!").equals(pointer().get("generation"));
|
||||||
|
op.put("state", wasCommitted ? "COMPLETED" : "INTERRUPTED");
|
||||||
|
op.put("message", wasCommitted ? "Recovered committed activation" : "Interrupted before activation");
|
||||||
|
write(journal, op);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Files.deleteIfExists(root.resolve("gate.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void pruneLeases() throws IOException {
|
||||||
|
try (var files = Files.list(directory("leases"))) {
|
||||||
|
for (Path file : files.toList()) {
|
||||||
|
if (!isOwnerAlive(read(file))) {
|
||||||
|
// A dead JVM is not proof its tool descendants stopped writing the home.
|
||||||
|
// Preserve admission until an operator verifies and removes the orphan lease.
|
||||||
|
throw new IllegalStateException("dsh.orphaned_runtime_requires_recovery");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isOwnerAlive(Map<String, String> value) {
|
||||||
|
try {
|
||||||
|
return ProcessHandle.of(Long.parseLong(value.getOrDefault("pid", "0")))
|
||||||
|
.filter(ProcessHandle::isAlive)
|
||||||
|
.flatMap(handle -> handle.info().startInstant())
|
||||||
|
.map(start -> start.toString().equals(value.get("started"))).orElse(false);
|
||||||
|
} catch (NumberFormatException error) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> pointer() throws IOException { return read(root.resolve("active.json")); }
|
||||||
|
|
||||||
|
private Path directory(String name) throws IOException {
|
||||||
|
Path path = name.isEmpty() ? root : root.resolve(name);
|
||||||
|
if (Files.isSymbolicLink(path)) throw new IOException("DSH managed directory must not be a symlink");
|
||||||
|
Files.createDirectories(path);
|
||||||
|
try { Files.setPosixFilePermissions(path, PosixFilePermissions.fromString("rwx------")); }
|
||||||
|
catch (UnsupportedOperationException ignored) { /* Non-POSIX hosts rely on the managed root ACL. */ }
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> read(Path path) throws IOException {
|
||||||
|
if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) return new HashMap<>();
|
||||||
|
if (Files.isSymbolicLink(path) || Files.size(path) > 1024 * 1024) throw new IOException("Invalid DSH metadata file");
|
||||||
|
return new HashMap<>(mapper.readValue(Files.readAllBytes(path), VALUES));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void write(Path path, Map<String, String> value) throws IOException {
|
||||||
|
Path temp = Files.createTempFile(path.getParent(), ".write-", ".json");
|
||||||
|
try {
|
||||||
|
try { Files.setPosixFilePermissions(temp, PosixFilePermissions.fromString("rw-------")); }
|
||||||
|
catch (UnsupportedOperationException ignored) { /* Inherit the managed root ACL. */ }
|
||||||
|
try (var channel = FileChannel.open(temp, StandardOpenOption.WRITE)) {
|
||||||
|
ByteBuffer bytes = ByteBuffer.wrap(mapper.writeValueAsBytes(value));
|
||||||
|
while (bytes.hasRemaining()) channel.write(bytes);
|
||||||
|
channel.force(true);
|
||||||
|
}
|
||||||
|
Files.move(temp, path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
try (var directory = FileChannel.open(path.getParent(), StandardOpenOption.READ)) { directory.force(true); }
|
||||||
|
catch (UnsupportedOperationException ignored) { /* Directory fsync is unavailable on some hosts. */ }
|
||||||
|
} finally { Files.deleteIfExists(temp); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> T locked(IoSupplier<T> action) {
|
||||||
|
ReentrantLock local = LOCKS.computeIfAbsent(root, ignored -> new ReentrantLock());
|
||||||
|
local.lock();
|
||||||
|
try {
|
||||||
|
directory("");
|
||||||
|
Path lockPath = root.resolve("control.lock");
|
||||||
|
if (Files.isSymbolicLink(lockPath)) throw new IOException("Invalid DSH lock");
|
||||||
|
try (var channel = FileChannel.open(lockPath, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
|
||||||
|
var lock = channel.lock()) { return action.get(); }
|
||||||
|
} catch (IOException error) { throw new IllegalStateException("dsh.storage_unavailable", error); }
|
||||||
|
finally { local.unlock(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void safeId(String id) {
|
||||||
|
if (id == null || !id.matches("[a-zA-Z0-9][a-zA-Z0-9-]{0,99}")) throw new IllegalArgumentException("dsh.invalid_identifier");
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface private interface IoSupplier<T> { T get() throws IOException; }
|
||||||
|
|
||||||
|
public final class Lease implements AutoCloseable {
|
||||||
|
private final Path path;
|
||||||
|
private Lease(Path path) { this.path = path; }
|
||||||
|
@Override public void close() { locked(() -> { Files.deleteIfExists(path); return null; }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,6 +8,8 @@ import vip.mate.common.result.R;
|
|||||||
import vip.mate.workspace.core.annotation.RequireGlobalAdmin;
|
import vip.mate.workspace.core.annotation.RequireGlobalAdmin;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
@Tag(name = "DeepSeek Harness Runtime Management")
|
@Tag(name = "DeepSeek Harness Runtime Management")
|
||||||
@RestController
|
@RestController
|
||||||
@ -15,11 +17,19 @@ import java.util.Map;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class DshManagementController {
|
public class DshManagementController {
|
||||||
private final DshManagementService managementService;
|
private final DshManagementService managementService;
|
||||||
|
private final DshUpgradeService upgrades;
|
||||||
|
|
||||||
|
public record UpgradeRequest(String targetVersion, String expectedRevision, String idempotencyKey) {}
|
||||||
|
public record RollbackRequest(String expectedRevision, String idempotencyKey) {}
|
||||||
|
|
||||||
@Operation(summary = "Get managed DSH runtime status")
|
@Operation(summary = "Get managed DSH runtime status")
|
||||||
@GetMapping("/status")
|
@GetMapping("/status")
|
||||||
@RequireGlobalAdmin
|
@RequireGlobalAdmin
|
||||||
public R<Map<String, Object>> status() { return R.ok(managementService.status()); }
|
public R<Map<String, Object>> status() {
|
||||||
|
Map<String, Object> status = new LinkedHashMap<>(managementService.status());
|
||||||
|
status.putAll(upgrades.status());
|
||||||
|
return R.ok(status);
|
||||||
|
}
|
||||||
|
|
||||||
@Operation(summary = "Save managed DSH runtime configuration")
|
@Operation(summary = "Save managed DSH runtime configuration")
|
||||||
@PutMapping("/config")
|
@PutMapping("/config")
|
||||||
@ -31,7 +41,29 @@ public class DshManagementController {
|
|||||||
@Operation(summary = "Install the server-selected DSH artifact")
|
@Operation(summary = "Install the server-selected DSH artifact")
|
||||||
@PostMapping("/install")
|
@PostMapping("/install")
|
||||||
@RequireGlobalAdmin
|
@RequireGlobalAdmin
|
||||||
public R<Map<String, Object>> install() throws Exception { return R.ok(managementService.install()); }
|
public R<Map<String, String>> install() {
|
||||||
|
return R.ok(upgrades.upgrade("0.2.0-rc.1", (String) upgrades.status().get("configRevision"), UUID.randomUUID().toString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/test-task")
|
||||||
|
@RequireGlobalAdmin
|
||||||
|
public R<Map<String, Object>> testTask() { return R.ok(managementService.testTask()); }
|
||||||
|
|
||||||
|
@PostMapping("/upgrades")
|
||||||
|
@RequireGlobalAdmin
|
||||||
|
public R<Map<String, String>> upgrade(@RequestBody UpgradeRequest request) {
|
||||||
|
return R.ok(upgrades.upgrade(request.targetVersion(), request.expectedRevision(), request.idempotencyKey()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/upgrades/{id}")
|
||||||
|
@RequireGlobalAdmin
|
||||||
|
public R<Map<String, String>> operation(@PathVariable String id) { return R.ok(upgrades.operation(id)); }
|
||||||
|
|
||||||
|
@PostMapping("/upgrades/{id}/rollback")
|
||||||
|
@RequireGlobalAdmin
|
||||||
|
public R<Map<String, String>> rollback(@PathVariable String id, @RequestBody RollbackRequest request) {
|
||||||
|
return R.ok(upgrades.rollback(id, request.expectedRevision(), request.idempotencyKey()));
|
||||||
|
}
|
||||||
|
|
||||||
@Operation(summary = "Verify DSH runtime configuration")
|
@Operation(summary = "Verify DSH runtime configuration")
|
||||||
@PostMapping("/verify")
|
@PostMapping("/verify")
|
||||||
|
|||||||
@ -1,14 +1,16 @@
|
|||||||
package vip.mate.agent.runtime.dsh.management;
|
package vip.mate.agent.runtime.dsh.management;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import vip.mate.agent.runtime.dsh.DshRuntimeService;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import vip.mate.system.service.SystemSettingService;
|
import vip.mate.system.service.SystemSettingService;
|
||||||
|
import vip.mate.agent.runtime.dsh.DshLaunchSpec;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class DshManagementService {
|
public class DshManagementService {
|
||||||
@ -17,6 +19,10 @@ public class DshManagementService {
|
|||||||
private final DshRuntimeConfigService configService;
|
private final DshRuntimeConfigService configService;
|
||||||
private final DshArtifactInstaller installer;
|
private final DshArtifactInstaller installer;
|
||||||
private final SystemSettingService settings;
|
private final SystemSettingService settings;
|
||||||
|
private volatile Map<String, Object> handshake = Map.of();
|
||||||
|
private volatile Map<String, Object> taskCheck = Map.of();
|
||||||
|
@Autowired
|
||||||
|
private DshRuntimeService runtime;
|
||||||
|
|
||||||
public DshManagementService(DshRuntimeConfigService configService,
|
public DshManagementService(DshRuntimeConfigService configService,
|
||||||
DshArtifactInstaller installer,
|
DshArtifactInstaller installer,
|
||||||
@ -29,23 +35,29 @@ public class DshManagementService {
|
|||||||
public Map<String, Object> status() {
|
public Map<String, Object> status() {
|
||||||
DshRuntimeConfiguration configuration = configService.resolve();
|
DshRuntimeConfiguration configuration = configService.resolve();
|
||||||
boolean executableAvailable = isExecutable(configuration.executablePath());
|
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
|
// An empty managed key is valid: DshRuntimeService can reuse the
|
||||||
// existing DeepSeek provider key. The page may still store a managed
|
// existing DeepSeek provider key. The page may still store a managed
|
||||||
// key when the operator wants DSH to be independent from model rows.
|
// key when the operator wants DSH to be independent from model rows.
|
||||||
|
boolean sdkConfigAvailable;
|
||||||
|
try { DshLaunchSpec.create(configuration, "health", "health", Path.of(configuration.workingDirectory())); sdkConfigAvailable = true; }
|
||||||
|
catch (Exception error) { sdkConfigAvailable = false; }
|
||||||
boolean enabled = settings.getBool(ENABLED_KEY, false);
|
boolean enabled = settings.getBool(ENABLED_KEY, false);
|
||||||
DshManagementState state;
|
DshManagementState state;
|
||||||
if (!executableAvailable) state = DshManagementState.NOT_INSTALLED;
|
if (configuration.migrationRequired()) state = DshManagementState.MIGRATION_REQUIRED;
|
||||||
else if (!workingDirectoryAvailable || !cordisAvailable) state = DshManagementState.CONFIG_INVALID;
|
else if (!executableAvailable) state = DshManagementState.NOT_INSTALLED;
|
||||||
|
else if (!sdkConfigAvailable) state = DshManagementState.CONFIG_INVALID;
|
||||||
else if (enabled) state = DshManagementState.ENABLED;
|
else if (enabled) state = DshManagementState.ENABLED;
|
||||||
else state = DshManagementState.READY;
|
else state = DshManagementState.READY;
|
||||||
|
|
||||||
Map<String, Object> result = new LinkedHashMap<>();
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
result.put("state", state.name());
|
result.put("state", state.name());
|
||||||
|
result.put("profile", configuration.profile());
|
||||||
|
String revision = Objects.toString(configService.revision(), "0");
|
||||||
|
result.put("handshake", revision.equals(handshake.get("configRevision")) ? handshake : Map.of());
|
||||||
|
result.put("taskCheck", revision.equals(taskCheck.get("configRevision")) ? taskCheck : Map.of());
|
||||||
|
result.put("configRevision", configService.revision());
|
||||||
|
result.put("versionStatus", "UNVERIFIED_VERSION");
|
||||||
|
result.put("fileCheck", Map.of("success", sdkConfigAvailable));
|
||||||
result.put("installed", executableAvailable);
|
result.put("installed", executableAvailable);
|
||||||
result.put("enabled", enabled);
|
result.put("enabled", enabled);
|
||||||
result.put("config", configuration.publicStatus());
|
result.put("config", configuration.publicStatus());
|
||||||
@ -61,17 +73,6 @@ public class DshManagementService {
|
|||||||
return status();
|
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() {
|
public Map<String, Object> verify() {
|
||||||
Map<String, Object> result = status();
|
Map<String, Object> result = status();
|
||||||
boolean ok = "ENABLED".equals(result.get("state")) || "READY".equals(result.get("state"));
|
boolean ok = "ENABLED".equals(result.get("state")) || "READY".equals(result.get("state"));
|
||||||
@ -80,41 +81,30 @@ public class DshManagementService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Map<String, Object> testConnection() {
|
public Map<String, Object> testConnection() { return check(false); }
|
||||||
DshRuntimeConfiguration configuration = configService.resolve();
|
public Map<String, Object> testTask() { return check(true); }
|
||||||
if (!isExecutable(configuration.executablePath())) return Map.of("success", false, "message", "DSH executable is unavailable");
|
|
||||||
if (configuration.cordisConfigPath() == null || configuration.cordisConfigPath().isBlank()) {
|
private Map<String, Object> check(boolean task) {
|
||||||
return Map.of("success", false, "message", "DSH Cordis configuration is unavailable");
|
DshGenerationStore.Lease lease = null;
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
ProcessBuilder builder = new ProcessBuilder(configuration.executablePath(), configuration.cordisConfigPath())
|
if (configService.generations() != null) lease = configService.generations().acquireLease("health", "health");
|
||||||
.directory(Path.of(configuration.workingDirectory()).toFile())
|
String revision = Objects.toString(configService.revision(), "0");
|
||||||
.redirectErrorStream(true);
|
DshRuntimeConfiguration configuration = configService.resolve();
|
||||||
builder.environment().put("DSH_CWD", configuration.workingDirectory());
|
if (runtime == null) return Map.of("success", false, "message", "DSH health service unavailable");
|
||||||
builder.environment().put("DSH_CORDIS_CONFIG", configuration.cordisConfigPath());
|
Map<String, Object> result = new LinkedHashMap<>(task ? runtime.testTask(configuration) : runtime.testConnection(configuration));
|
||||||
if (configuration.apiKey() != null && !configuration.apiKey().isBlank()) {
|
result.put("configRevision", revision);
|
||||||
builder.environment().put("DEEPSEEK_API_KEY", configuration.apiKey());
|
if (task) taskCheck = Map.copyOf(result); else handshake = Map.copyOf(result);
|
||||||
}
|
return result;
|
||||||
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) {
|
} catch (Exception error) {
|
||||||
return Map.of("success", false, "message", "DSH connection test failed: " + error.getMessage());
|
return Map.of("success", false, "message", "DSH health check unavailable");
|
||||||
}
|
} finally { if (lease != null) lease.close(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
public Map<String, Object> enable() {
|
public Map<String, Object> enable() {
|
||||||
Map<String, Object> current = verify();
|
Map<String, Object> current = verify();
|
||||||
if (!Boolean.TRUE.equals(current.get("verified"))) throw new IllegalStateException("DSH must pass verification before enabling");
|
if (!Boolean.TRUE.equals(current.get("verified"))) throw new IllegalStateException("DSH must pass verification before enabling");
|
||||||
|
Map<String, Object> checked = testConnection();
|
||||||
|
if (!Boolean.TRUE.equals(checked.get("success"))) throw new IllegalStateException("DSH must pass the SDK handshake before enabling");
|
||||||
settings.saveBool(ENABLED_KEY, true, "Enable managed DeepSeek Harness runtime");
|
settings.saveBool(ENABLED_KEY, true, "Enable managed DeepSeek Harness runtime");
|
||||||
return status();
|
return status();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package vip.mate.agent.runtime.dsh.management;
|
|||||||
|
|
||||||
/** Lifecycle states exposed by the DSH runtime management screen. */
|
/** Lifecycle states exposed by the DSH runtime management screen. */
|
||||||
public enum DshManagementState {
|
public enum DshManagementState {
|
||||||
|
MIGRATION_REQUIRED,
|
||||||
NOT_INSTALLED,
|
NOT_INSTALLED,
|
||||||
INSTALLING,
|
INSTALLING,
|
||||||
INSTALLED_UNCONFIGURED,
|
INSTALLED_UNCONFIGURED,
|
||||||
|
|||||||
@ -0,0 +1,94 @@
|
|||||||
|
package vip.mate.agent.runtime.dsh.management;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.RandomAccessFile;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/** Installs exact SDK packages into a candidate prefix without touching global npm state. */
|
||||||
|
@Component
|
||||||
|
public class DshPinnedInstaller {
|
||||||
|
public record Release(String version, String channel, String integrity) {}
|
||||||
|
public record Installation(String version, String platform, String executablePath, String integrity) {}
|
||||||
|
private static final Map<String, Release> CATALOG = Map.of(
|
||||||
|
"0.2.0-rc.1", new Release("0.2.0-rc.1", "next",
|
||||||
|
"sha512-F6hKNVoGgBDIzSiyRaIlobq4UD6cwxUjh+nwXqcDmufDh87TE1izsYzs8L5cZNpF2JmPnFM1mXRNnRJ0cs43ng=="),
|
||||||
|
"0.1.7-rc.2", new Release("0.1.7-rc.2", "latest",
|
||||||
|
"sha512-SQFhriLvza8GnFApnC5/32AgpcyKxrWnYXhvwDOLJdgWpkCX2EexyR9c8kCkMITJXnFLEN3Qb2CEh0W36vkLyw=="));
|
||||||
|
private final ObjectMapper mapper;
|
||||||
|
public DshPinnedInstaller(ObjectMapper mapper) { this.mapper = mapper; }
|
||||||
|
|
||||||
|
public static Release release(String version) {
|
||||||
|
Release release = CATALOG.get(version);
|
||||||
|
if (release == null) throw new IllegalArgumentException("dsh.unsupported_version: select an exact catalog version");
|
||||||
|
return release;
|
||||||
|
}
|
||||||
|
public static List<Release> catalog() { return List.of(release("0.2.0-rc.1"), release("0.1.7-rc.2")); }
|
||||||
|
|
||||||
|
public static String platform(String os, String arch) {
|
||||||
|
if (os.toLowerCase().contains("linux") && (arch.equals("amd64") || arch.equals("x86_64"))) return "linux-x64";
|
||||||
|
if (os.toLowerCase().contains("mac") && (arch.equals("aarch64") || arch.equals("arm64"))) return "darwin-arm64";
|
||||||
|
throw new IllegalArgumentException("dsh.unsupported_platform: managed install supports Linux x64 and macOS arm64 candidates");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Installation install(String version, Path prefix) throws Exception {
|
||||||
|
Release release = release(version);
|
||||||
|
String platform = platform(System.getProperty("os.name"), System.getProperty("os.arch"));
|
||||||
|
if (Files.exists(prefix)) throw new IllegalArgumentException("dsh.candidate_directory_exists");
|
||||||
|
Files.createDirectories(prefix);
|
||||||
|
Path log = prefix.resolve("install.log");
|
||||||
|
String nodeVersion = execute(List.of("node", "--version"), prefix, log, Duration.ofSeconds(10)).trim();
|
||||||
|
if (!nodeVersion.matches("v22\\..*")) throw new IllegalStateException("dsh.node_version_unverified: Node.js 22 is required");
|
||||||
|
// npm ci verifies every tarball against the reviewed lock before lifecycle execution.
|
||||||
|
try (var input = new ClassPathResource("dsh/locks/" + version + ".json").getInputStream()) {
|
||||||
|
Files.copy(input, prefix.resolve("package-lock.json"));
|
||||||
|
}
|
||||||
|
JsonNode trusted = mapper.readTree(prefix.resolve("package-lock.json").toFile());
|
||||||
|
if (!release.integrity().equals(trusted.path("packages").path("node_modules/@deepseek-ai/dsh").path("integrity").asText())) {
|
||||||
|
throw new IllegalStateException("dsh.catalog_integrity_mismatch");
|
||||||
|
}
|
||||||
|
mapper.writeValue(prefix.resolve("package.json").toFile(), Map.of("name", "mateclaw-dsh-runtime",
|
||||||
|
"version", "1.0.0", "private", true, "dependencies", Map.of("@deepseek-ai/dsh", version)));
|
||||||
|
execute(List.of("npm", "ci", "--prefix", prefix.toString(), "--registry=https://registry.npmjs.org",
|
||||||
|
"--no-audit", "--no-fund"), prefix, log, Duration.ofMinutes(5));
|
||||||
|
JsonNode lock = mapper.readTree(prefix.resolve("package-lock.json").toFile());
|
||||||
|
JsonNode installed = lock.path("packages").path("node_modules/@deepseek-ai/dsh");
|
||||||
|
if (!version.equals(installed.path("version").asText()) || !release.integrity().equals(installed.path("integrity").asText())) {
|
||||||
|
throw new IllegalStateException("dsh.package_integrity_mismatch");
|
||||||
|
}
|
||||||
|
Path executable = prefix.resolve("node_modules/.bin/dsh");
|
||||||
|
if (!Files.isExecutable(executable)) throw new IllegalStateException("dsh.executable_missing");
|
||||||
|
JsonNode packageJson = mapper.readTree(prefix.resolve("node_modules/@deepseek-ai/dsh/package.json").toFile());
|
||||||
|
if (!version.equals(packageJson.path("version").asText())) throw new IllegalStateException("dsh.package_version_mismatch");
|
||||||
|
return new Installation(version, platform, executable.toString(), release.integrity());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String execute(List<String> argv, Path cwd, Path log, Duration timeout) throws Exception {
|
||||||
|
ProcessBuilder builder = new ProcessBuilder(argv).directory(cwd.toFile()).redirectErrorStream(true)
|
||||||
|
.redirectOutput(log.toFile());
|
||||||
|
Process process = builder.start();
|
||||||
|
try {
|
||||||
|
if (!process.waitFor(timeout.toMillis(), TimeUnit.MILLISECONDS)) throw new IllegalStateException("dsh.install_timeout");
|
||||||
|
if (process.exitValue() != 0) throw new IllegalStateException("dsh.install_failed: inspect the protected candidate install log");
|
||||||
|
try (RandomAccessFile output = new RandomAccessFile(log.toFile(), "r")) {
|
||||||
|
output.seek(Math.max(0, output.length() - 65536));
|
||||||
|
byte[] tail = new byte[(int) Math.min(65536, output.length())];
|
||||||
|
output.readFully(tail);
|
||||||
|
return new String(tail, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
process.descendants().forEach(child -> { child.destroy(); if (child.isAlive()) child.destroyForcibly(); });
|
||||||
|
if (process.isAlive()) process.destroyForcibly();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,9 @@
|
|||||||
package vip.mate.agent.runtime.dsh.management;
|
package vip.mate.agent.runtime.dsh.management;
|
||||||
|
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/** Resolves managed settings first, then application properties, then legacy environment variables. */
|
/** Resolves managed settings first, then application properties, then legacy environment variables. */
|
||||||
@ -24,7 +28,24 @@ public final class DshRuntimeConfigResolver {
|
|||||||
firstNonBlank(managed, properties, environment,
|
firstNonBlank(managed, properties, environment,
|
||||||
"dsh.model_name", "mateclaw.agent.runtime.dsh.model-name", "DEEPSEEK_MODEL"),
|
"dsh.model_name", "mateclaw.agent.runtime.dsh.model-name", "DEEPSEEK_MODEL"),
|
||||||
firstNonBlank(managed, properties, environment,
|
firstNonBlank(managed, properties, environment,
|
||||||
"dsh.api_key", "mateclaw.agent.runtime.dsh.api-key", "DEEPSEEK_API_KEY"));
|
"dsh.api_key", "mateclaw.agent.runtime.dsh.api-key", "DEEPSEEK_API_KEY"),
|
||||||
|
firstNonBlank(managed, properties, environment, "dsh.profile", "mateclaw.agent.runtime.dsh.profile", "DSH_PROFILE"),
|
||||||
|
patches(firstNonBlank(managed, properties, environment, "dsh.patch_paths", "mateclaw.agent.runtime.dsh.patch-paths", "DSH_PATCH_PATHS")),
|
||||||
|
firstNonBlank(managed, properties, environment, "dsh.home_root", "mateclaw.agent.runtime.dsh.home-root", "MATECLAW_DSH_HOME_ROOT"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> patches(String value) {
|
||||||
|
if (value == null || value.isBlank()) return List.of();
|
||||||
|
try {
|
||||||
|
var node = new ObjectMapper().readTree(value);
|
||||||
|
if (!node.isArray()) throw new IllegalArgumentException();
|
||||||
|
List<String> paths = new ArrayList<>();
|
||||||
|
for (var entry : node) {
|
||||||
|
if (!entry.isTextual() || entry.asText().isBlank()) throw new IllegalArgumentException();
|
||||||
|
paths.add(entry.asText());
|
||||||
|
}
|
||||||
|
return List.copyOf(paths);
|
||||||
|
} catch (Exception error) { throw new IllegalArgumentException("DSH_CONFIG_INVALID: patch_paths must be a JSON array of paths"); }
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String firstNonBlank(
|
private static String firstNonBlank(
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
package vip.mate.agent.runtime.dsh.management;
|
package vip.mate.agent.runtime.dsh.management;
|
||||||
|
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import vip.mate.system.service.SystemSettingService;
|
import vip.mate.system.service.SystemSettingService;
|
||||||
|
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
@ -14,11 +17,24 @@ import java.nio.file.Path;
|
|||||||
public class DshRuntimeConfigService {
|
public class DshRuntimeConfigService {
|
||||||
private static final String[] MANAGED_KEYS = {
|
private static final String[] MANAGED_KEYS = {
|
||||||
"dsh.executable_path", "dsh.cordis_config_path", "dsh.working_directory",
|
"dsh.executable_path", "dsh.cordis_config_path", "dsh.working_directory",
|
||||||
"dsh.base_url", "dsh.model_name", SystemSettingService.DSH_API_KEY_KEY
|
"dsh.base_url", "dsh.model_name", SystemSettingService.DSH_API_KEY_KEY,
|
||||||
|
"dsh.profile", "dsh.patch_paths", "dsh.home_root"
|
||||||
};
|
};
|
||||||
|
|
||||||
private final SystemSettingService settings;
|
private final SystemSettingService settings;
|
||||||
private final Map<String, String> properties;
|
private final Map<String, String> properties;
|
||||||
|
@Autowired(required = false)
|
||||||
|
private DshGenerationStore generations;
|
||||||
|
@Value("${mateclaw.agent.runtime.dsh.profile:sdk}")
|
||||||
|
private String profile = "sdk";
|
||||||
|
@Value("${mateclaw.agent.runtime.dsh.patch-paths:[]}")
|
||||||
|
private String patchPaths = "[]";
|
||||||
|
@Value("${mateclaw.agent.runtime.dsh.home-root:}")
|
||||||
|
private String homeRoot = "";
|
||||||
|
|
||||||
|
public DshGenerationStore generations() { return generations; }
|
||||||
|
public String revision() { return generations == null ? "0" : generations.revision(); }
|
||||||
|
|
||||||
|
|
||||||
public DshRuntimeConfigService(
|
public DshRuntimeConfigService(
|
||||||
SystemSettingService settings,
|
SystemSettingService settings,
|
||||||
@ -39,43 +55,31 @@ public class DshRuntimeConfigService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public DshRuntimeConfiguration resolve() {
|
public DshRuntimeConfiguration resolve() {
|
||||||
|
return resolve(Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
private DshRuntimeConfiguration resolve(Map<String, String> submitted) {
|
||||||
Map<String, String> managed = new LinkedHashMap<>();
|
Map<String, String> managed = new LinkedHashMap<>();
|
||||||
for (String key : MANAGED_KEYS) {
|
for (String key : MANAGED_KEYS) {
|
||||||
String defaultValue = key.equals("dsh.working_directory") ? "" : null;
|
String defaultValue = key.equals("dsh.working_directory") ? "" : null;
|
||||||
managed.put(key, settings.getString(key, defaultValue));
|
managed.put(key, settings.getString(key, defaultValue));
|
||||||
}
|
}
|
||||||
DshRuntimeConfiguration resolved = DshRuntimeConfigResolver.resolve(managed, properties, System.getenv());
|
Map<String, String> active = generations == null ? Map.of() : generations.activeValues();
|
||||||
|
managed.putAll(active);
|
||||||
|
managed.putAll(submitted);
|
||||||
|
if (managed.get("dsh.home_root") == null || managed.get("dsh.home_root").isBlank()) {
|
||||||
|
if (!homeRoot.isBlank()) managed.put("dsh.home_root", homeRoot);
|
||||||
|
else if (generations != null) managed.put("dsh.home_root", generations.root().resolve("homes").toString());
|
||||||
|
}
|
||||||
|
Map<String, String> runtimeProperties = new LinkedHashMap<>(properties);
|
||||||
|
runtimeProperties.put("mateclaw.agent.runtime.dsh.profile", profile);
|
||||||
|
runtimeProperties.put("mateclaw.agent.runtime.dsh.patch-paths", patchPaths);
|
||||||
|
DshRuntimeConfiguration resolved = DshRuntimeConfigResolver.resolve(managed,
|
||||||
|
active.isEmpty() ? runtimeProperties : Map.of(), active.isEmpty() ? System.getenv() : Map.of());
|
||||||
String workingDirectory = resolved.workingDirectory();
|
String workingDirectory = resolved.workingDirectory();
|
||||||
if (workingDirectory == null || workingDirectory.isBlank()) workingDirectory = System.getProperty("user.dir");
|
if (workingDirectory == null || workingDirectory.isBlank()) workingDirectory = System.getProperty("user.dir");
|
||||||
String cordisConfig = normalizeCordisConfig(resolved.cordisConfigPath());
|
return new DshRuntimeConfiguration(resolved.executablePath(), resolved.cordisConfigPath(), workingDirectory,
|
||||||
if (cordisConfig.isBlank()) cordisConfig = discoverCordisConfig(resolved.executablePath());
|
resolved.baseUrl(), resolved.modelName(), resolved.apiKey(), resolved.profile(), resolved.patchPaths(), resolved.homeRoot());
|
||||||
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() {
|
public Map<String, String> managedValues() {
|
||||||
@ -88,16 +92,51 @@ public class DshRuntimeConfigService {
|
|||||||
values.put(key, value == null ? "" : value);
|
values.put(key, value == null ? "" : value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (generations != null) {
|
||||||
|
generations.activeValues().forEach((key, value) -> {
|
||||||
|
if (Arrays.asList(MANAGED_KEYS).contains(key)) values.put(key,
|
||||||
|
SystemSettingService.DSH_API_KEY_KEY.equals(key) ? settings.maskSecret(value) : value);
|
||||||
|
});
|
||||||
|
}
|
||||||
return values;
|
return values;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void save(Map<String, String> values) {
|
public void save(Map<String, String> values) {
|
||||||
if (values == null) return;
|
if (values == null) return;
|
||||||
|
Map<String, String> submitted = new LinkedHashMap<>();
|
||||||
|
for (String key : MANAGED_KEYS) {
|
||||||
|
String value = values.get(key);
|
||||||
|
if (value == null) continue;
|
||||||
|
if (SystemSettingService.DSH_API_KEY_KEY.equals(key) && (value.isBlank() || value.startsWith("****"))) continue;
|
||||||
|
submitted.put(key, value);
|
||||||
|
}
|
||||||
|
String expectedRevision = generations == null ? null : generations.revision();
|
||||||
|
// Apply repairs before parsing; malformed stored fields must remain editable.
|
||||||
|
DshRuntimeConfiguration current = resolve(submitted);
|
||||||
|
if (!"sdk".equals(current.profile())) throw new IllegalArgumentException("DSH_PROFILE_INVALID: profile must be sdk");
|
||||||
|
if (generations != null) {
|
||||||
|
Map<String, String> snapshot = new LinkedHashMap<>();
|
||||||
|
snapshot.put("dsh.executable_path", current.executablePath());
|
||||||
|
snapshot.put("dsh.cordis_config_path", current.cordisConfigPath());
|
||||||
|
snapshot.put("dsh.working_directory", current.workingDirectory());
|
||||||
|
snapshot.put("dsh.base_url", current.baseUrl() == null ? "" : current.baseUrl());
|
||||||
|
snapshot.put("dsh.model_name", current.modelName() == null ? "" : current.modelName());
|
||||||
|
snapshot.put("dsh.api_key", current.apiKey() == null ? "" : current.apiKey());
|
||||||
|
snapshot.put("dsh.profile", current.profile());
|
||||||
|
snapshot.put("dsh.home_root", current.homeRoot());
|
||||||
|
try { snapshot.put("dsh.patch_paths", new ObjectMapper().writeValueAsString(current.patchPaths())); }
|
||||||
|
catch (Exception error) { throw new IllegalArgumentException("Invalid DSH patches", error); }
|
||||||
|
generations.updateActiveValues(snapshot, expectedRevision);
|
||||||
|
return;
|
||||||
|
}
|
||||||
save(values, "dsh.executable_path");
|
save(values, "dsh.executable_path");
|
||||||
save(values, "dsh.cordis_config_path");
|
save(values, "dsh.cordis_config_path");
|
||||||
save(values, "dsh.working_directory");
|
save(values, "dsh.working_directory");
|
||||||
save(values, "dsh.base_url");
|
save(values, "dsh.base_url");
|
||||||
save(values, "dsh.model_name");
|
save(values, "dsh.model_name");
|
||||||
|
save(values, "dsh.profile");
|
||||||
|
save(values, "dsh.patch_paths");
|
||||||
|
save(values, "dsh.home_root");
|
||||||
String apiKey = values.get(SystemSettingService.DSH_API_KEY_KEY);
|
String apiKey = values.get(SystemSettingService.DSH_API_KEY_KEY);
|
||||||
if (apiKey != null && !apiKey.isBlank() && !apiKey.startsWith("****")) {
|
if (apiKey != null && !apiKey.isBlank() && !apiKey.startsWith("****")) {
|
||||||
settings.saveString(SystemSettingService.DSH_API_KEY_KEY, apiKey.trim(), "DeepSeek API key for DSH");
|
settings.saveString(SystemSettingService.DSH_API_KEY_KEY, apiKey.trim(), "DeepSeek API key for DSH");
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
package vip.mate.agent.runtime.dsh.management;
|
package vip.mate.agent.runtime.dsh.management;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@ -10,10 +13,41 @@ public record DshRuntimeConfiguration(
|
|||||||
String workingDirectory,
|
String workingDirectory,
|
||||||
String baseUrl,
|
String baseUrl,
|
||||||
String modelName,
|
String modelName,
|
||||||
String apiKey) {
|
String apiKey,
|
||||||
|
String profile,
|
||||||
|
List<String> patchPaths,
|
||||||
|
String homeRoot) {
|
||||||
|
|
||||||
|
public DshRuntimeConfiguration(String executablePath, String cordisConfigPath, String workingDirectory,
|
||||||
|
String baseUrl, String modelName, String apiKey) {
|
||||||
|
this(executablePath, cordisConfigPath, workingDirectory, baseUrl, modelName, apiKey,
|
||||||
|
"sdk", List.of(), Path.of(System.getProperty("user.home"), ".mateclaw", "runtimes", "deepseek-harness", "homes").toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public DshRuntimeConfiguration {
|
||||||
|
executablePath = executablePath == null ? "" : executablePath;
|
||||||
|
cordisConfigPath = cordisConfigPath == null ? "" : cordisConfigPath;
|
||||||
|
profile = profile == null || profile.isBlank() ? "sdk" : profile;
|
||||||
|
patchPaths = patchPaths == null ? List.of() : List.copyOf(patchPaths);
|
||||||
|
homeRoot = homeRoot == null || homeRoot.isBlank()
|
||||||
|
? Path.of(System.getProperty("user.home"), ".mateclaw", "runtimes", "deepseek-harness", "homes").toString() : homeRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean migrationRequired() {
|
||||||
|
return !cordisConfigPath.isBlank() || executablePath.contains("dsh-jsonrpc-agent")
|
||||||
|
|| executablePath.contains(" --")
|
||||||
|
|| (executablePath.chars().anyMatch(Character::isWhitespace) && !Files.isRegularFile(Path.of(executablePath))) || executablePath.startsWith("\"") || executablePath.startsWith("'");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public String toString() { return "DshRuntimeConfiguration[profile=" + profile + ", credentials=redacted]"; }
|
||||||
|
|
||||||
|
|
||||||
public Map<String, Object> publicStatus() {
|
public Map<String, Object> publicStatus() {
|
||||||
Map<String, Object> status = new LinkedHashMap<>();
|
Map<String, Object> status = new LinkedHashMap<>();
|
||||||
|
status.put("profile", profile);
|
||||||
|
status.put("patchPaths", patchPaths);
|
||||||
|
status.put("homeRoot", homeRoot);
|
||||||
|
status.put("versionStatus", "UNVERIFIED_VERSION");
|
||||||
status.put("executablePath", executablePath);
|
status.put("executablePath", executablePath);
|
||||||
status.put("cordisConfigPath", cordisConfigPath);
|
status.put("cordisConfigPath", cordisConfigPath);
|
||||||
status.put("workingDirectory", workingDirectory);
|
status.put("workingDirectory", workingDirectory);
|
||||||
|
|||||||
@ -0,0 +1,237 @@
|
|||||||
|
package vip.mate.agent.runtime.dsh.management;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import vip.mate.agent.runtime.dsh.DshRuntimeService;
|
||||||
|
import vip.mate.workspace.core.service.MemberFileIsolation;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.FileVisitResult;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.SimpleFileVisitor;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.nio.file.attribute.BasicFileAttributes;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.Executor;
|
||||||
|
|
||||||
|
/** Asynchronous, journaled preparation and activation of isolated runtime generations. */
|
||||||
|
@Service
|
||||||
|
public class DshUpgradeService {
|
||||||
|
private final DshGenerationStore store;
|
||||||
|
private final DshRuntimeConfigService config;
|
||||||
|
private final DshRuntimeService runtime;
|
||||||
|
private final DshPinnedInstaller installer;
|
||||||
|
private final ObjectMapper mapper;
|
||||||
|
private final Executor executor;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public DshUpgradeService(DshGenerationStore store, DshRuntimeConfigService config,
|
||||||
|
DshRuntimeService runtime, DshPinnedInstaller installer, ObjectMapper mapper) {
|
||||||
|
this(store, config, runtime, installer, mapper, task -> Thread.ofVirtual().name("dsh-upgrade").start(task));
|
||||||
|
}
|
||||||
|
|
||||||
|
DshUpgradeService(DshGenerationStore store, DshRuntimeConfigService config,
|
||||||
|
DshRuntimeService runtime, DshPinnedInstaller installer, ObjectMapper mapper, Executor executor) {
|
||||||
|
this.store = store; this.config = config; this.runtime = runtime; this.installer = installer;
|
||||||
|
this.mapper = mapper; this.executor = executor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> status() {
|
||||||
|
return Map.of("configRevision", store.revision(), "activeGeneration", store.activeGeneration(),
|
||||||
|
"activeVersion", store.activeValues().getOrDefault("dsh.runtime_version", "unknown"),
|
||||||
|
"catalog", DshPinnedInstaller.catalog(), "supportStatus", "CANDIDATE_REQUIRES_VALIDATION");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> upgrade(String targetVersion, String expectedRevision, String idempotencyKey) {
|
||||||
|
DshPinnedInstaller.release(targetVersion);
|
||||||
|
return submit("upgrade", targetVersion, "", expectedRevision, idempotencyKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> rollback(String originalOperation, String expectedRevision, String idempotencyKey) {
|
||||||
|
Map<String, String> original = store.operation(originalOperation);
|
||||||
|
if (!"COMPLETED".equals(original.get("state")) || original.getOrDefault("previousGeneration", "").isBlank()) {
|
||||||
|
throw new IllegalStateException("dsh.rollback_unavailable");
|
||||||
|
}
|
||||||
|
if (idempotencyKey == null || !idempotencyKey.matches("[a-zA-Z0-9-]{1,100}")) throw new IllegalArgumentException("dsh.invalid_idempotency_key");
|
||||||
|
Map<String, String> prior = null;
|
||||||
|
try { prior = operation(digest("rollback:" + idempotencyKey)); }
|
||||||
|
catch (IllegalArgumentException missing) {
|
||||||
|
if (!"dsh.operation_missing".equals(missing.getMessage())) throw missing;
|
||||||
|
}
|
||||||
|
if (prior != null) {
|
||||||
|
if (!original.get("previousGeneration").equals(prior.get("restoreGeneration"))
|
||||||
|
|| !prior.get("expectedRevision").equals(expectedRevision)) throw new IllegalStateException("dsh.idempotency_conflict");
|
||||||
|
return prior;
|
||||||
|
}
|
||||||
|
if (!original.get("candidateGeneration").equals(store.activeGeneration())) {
|
||||||
|
throw new IllegalStateException("dsh.rollback_generation_changed");
|
||||||
|
}
|
||||||
|
String previous = original.get("previousGeneration");
|
||||||
|
return submit("rollback", store.generationValues(previous).getOrDefault("dsh.runtime_version", "unknown"),
|
||||||
|
previous, expectedRevision, idempotencyKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> operation(String id) { return store.operation(id); }
|
||||||
|
|
||||||
|
private Map<String, String> submit(String kind, String version, String restore, String revision, String key) {
|
||||||
|
if (MemberFileIsolation.isEnabled()) throw new SecurityException("DSH runtime is unavailable in member file isolation mode");
|
||||||
|
if (key == null || !key.matches("[a-zA-Z0-9-]{1,100}")) throw new IllegalArgumentException("dsh.invalid_idempotency_key");
|
||||||
|
String id = digest(kind + ":" + key);
|
||||||
|
Map<String, String> op = DshGenerationStore.owner();
|
||||||
|
op.putAll(Map.of("id", id, "kind", kind, "targetVersion", version, "restoreGeneration", restore,
|
||||||
|
"expectedRevision", revision == null ? "" : revision, "state", "QUEUED", "createdAt", Instant.now().toString()));
|
||||||
|
if (store.createOperation(id, op)) executor.execute(() -> run(new HashMap<>(op)));
|
||||||
|
Map<String, String> existing = operation(id);
|
||||||
|
if (!version.equals(existing.get("targetVersion")) || !op.get("expectedRevision").equals(existing.get("expectedRevision"))
|
||||||
|
|| !restore.equals(existing.get("restoreGeneration"))) throw new IllegalStateException("dsh.idempotency_conflict");
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void run(Map<String, String> op) {
|
||||||
|
String id = op.get("id");
|
||||||
|
boolean gate = false;
|
||||||
|
try {
|
||||||
|
String candidate = "run-" + UUID.randomUUID();
|
||||||
|
op.put("candidateGeneration", candidate);
|
||||||
|
Path directory = store.root().resolve("releases").resolve(candidate);
|
||||||
|
transition(op, "PREPARING", "Preparing an isolated candidate");
|
||||||
|
DshPinnedInstaller.Installation installation = null;
|
||||||
|
if (op.get("kind").equals("upgrade")) installation = installer.install(op.get("targetVersion"), directory.resolve("package"));
|
||||||
|
store.beginUpgrade(id, op.get("expectedRevision"));
|
||||||
|
gate = true;
|
||||||
|
transition(op, "DRAINING", "Waiting for active DSH turns; new DSH turns are temporarily blocked");
|
||||||
|
if (!store.awaitDrained(Duration.ofSeconds(60))) throw new IllegalStateException("dsh.drain_timeout");
|
||||||
|
Map<String, String> current = snapshot(config.resolve());
|
||||||
|
current.putAll(store.activeValues());
|
||||||
|
String previous = store.activeGeneration();
|
||||||
|
if (previous.isBlank()) {
|
||||||
|
previous = "baseline-" + UUID.randomUUID();
|
||||||
|
store.saveSnapshot(id, previous, current);
|
||||||
|
}
|
||||||
|
op.put("previousGeneration", previous);
|
||||||
|
Map<String, String> candidateValues = op.get("kind").equals("rollback")
|
||||||
|
? store.generationValues(op.get("restoreGeneration")) : new HashMap<>(current);
|
||||||
|
Path source = Path.of(candidateValues.get("dsh.home_root")).toAbsolutePath().normalize();
|
||||||
|
Path home = directory.resolve("homes");
|
||||||
|
transition(op, "SNAPSHOTTING", "Copying runtime data; original homes remain unchanged");
|
||||||
|
copyHome(source, home);
|
||||||
|
candidateValues.put("dsh.home_root", home.toString());
|
||||||
|
snapshotPatches(candidateValues, directory.resolve("patches"));
|
||||||
|
if (installation != null) {
|
||||||
|
candidateValues.put("dsh.executable_path", installation.executablePath());
|
||||||
|
candidateValues.put("dsh.runtime_version", installation.version());
|
||||||
|
candidateValues.put("dsh.platform", installation.platform());
|
||||||
|
candidateValues.put("dsh.integrity", installation.integrity());
|
||||||
|
candidateValues.put("dsh.profile", "sdk");
|
||||||
|
candidateValues.put("dsh.cordis_config_path", "");
|
||||||
|
}
|
||||||
|
transition(op, "CHECKING", "Checking candidate SDK and model task");
|
||||||
|
DshRuntimeConfiguration resolved = DshRuntimeConfigResolver.resolve(candidateValues, Map.of(), Map.of());
|
||||||
|
List<Path> homes = new ArrayList<>();
|
||||||
|
try (var paths = Files.walk(home)) {
|
||||||
|
paths.filter(path -> path.getFileName().toString().equals("profiles") && Files.isDirectory(path))
|
||||||
|
.map(Path::getParent).forEach(homes::add);
|
||||||
|
}
|
||||||
|
if (homes.isEmpty()) homes.add(Files.createDirectories(home.resolve("candidate-check")));
|
||||||
|
int checked = 0;
|
||||||
|
for (Path affectedHome : homes) {
|
||||||
|
Map<String, Object> handshake = runtime.testConnectionAtHome(resolved, affectedHome);
|
||||||
|
op.put("handshakeStatus", Boolean.TRUE.equals(handshake.get("success")) ? "PASSED" : "FAILED");
|
||||||
|
op.put("handshakeCheckedAt", Instant.now().toString());
|
||||||
|
store.saveOperation(id, op);
|
||||||
|
if (!Boolean.TRUE.equals(handshake.get("success"))) throw new IllegalStateException("dsh.candidate_handshake_failed");
|
||||||
|
Map<String, Object> task = runtime.testTaskAtHome(resolved, affectedHome);
|
||||||
|
op.put("taskStatus", Boolean.TRUE.equals(task.get("success")) ? "PASSED" : "FAILED");
|
||||||
|
op.put("taskCheckedAt", Instant.now().toString());
|
||||||
|
op.put("homesChecked", Integer.toString(++checked));
|
||||||
|
store.saveOperation(id, op);
|
||||||
|
if (!Boolean.TRUE.equals(task.get("success"))) throw new IllegalStateException("dsh.candidate_model_check_failed");
|
||||||
|
}
|
||||||
|
transition(op, "ACTIVATING", "Candidate checks passed; committing generation");
|
||||||
|
store.activate(id, candidate, candidateValues);
|
||||||
|
transition(op, "COMPLETED", "Version and home switched together; old generation retained");
|
||||||
|
} catch (Exception error) {
|
||||||
|
if (error instanceof InterruptedException) Thread.currentThread().interrupt();
|
||||||
|
if (op.getOrDefault("candidateGeneration", "!").equals(store.activeGeneration())) {
|
||||||
|
transition(op, "COMPLETED", "Activation committed; recovered completion record");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String message = error.getMessage();
|
||||||
|
transition(op, "FAILED", message != null && message.matches("dsh\\.[a-z_]+")
|
||||||
|
? message : "Candidate preparation failed; active generation retained");
|
||||||
|
} finally {
|
||||||
|
if (gate) store.finishUpgrade(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> snapshot(DshRuntimeConfiguration c) throws IOException {
|
||||||
|
Map<String, String> values = new HashMap<>();
|
||||||
|
values.put("dsh.executable_path", c.executablePath());
|
||||||
|
values.put("dsh.cordis_config_path", c.cordisConfigPath());
|
||||||
|
values.put("dsh.working_directory", c.workingDirectory());
|
||||||
|
values.put("dsh.base_url", c.baseUrl());
|
||||||
|
values.put("dsh.model_name", c.modelName());
|
||||||
|
values.put("dsh.api_key", c.apiKey());
|
||||||
|
values.put("dsh.profile", c.profile());
|
||||||
|
values.put("dsh.patch_paths", mapper.writeValueAsString(c.patchPaths()));
|
||||||
|
values.put("dsh.home_root", c.homeRoot());
|
||||||
|
values.replaceAll((k, v) -> v == null ? "" : v);
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void snapshotPatches(Map<String, String> values, Path destination) throws IOException {
|
||||||
|
List<String> patches = mapper.readValue(values.getOrDefault("dsh.patch_paths", "[]"), new TypeReference<>() {});
|
||||||
|
List<String> copied = new ArrayList<>();
|
||||||
|
for (int i = 0; i < patches.size(); i++) {
|
||||||
|
Path path = Path.of(patches.get(i));
|
||||||
|
if (!path.isAbsolute() || Files.isSymbolicLink(path) || !Files.isRegularFile(path)) throw new IOException("Unsafe patch");
|
||||||
|
Files.createDirectories(destination);
|
||||||
|
Path target = destination.resolve(i + ".yml");
|
||||||
|
Files.copy(path, target);
|
||||||
|
copied.add(target.toString());
|
||||||
|
}
|
||||||
|
values.put("dsh.patch_paths", mapper.writeValueAsString(copied));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void copyHome(Path source, Path destination) throws IOException {
|
||||||
|
source = source.toAbsolutePath().normalize();
|
||||||
|
destination = destination.toAbsolutePath().normalize();
|
||||||
|
if (destination.startsWith(source) || source.startsWith(destination)) throw new IOException("Overlapping home snapshot");
|
||||||
|
if (Files.isSymbolicLink(source)) throw new IOException("Home symlinks require explicit migration");
|
||||||
|
Files.createDirectories(destination);
|
||||||
|
if (!Files.exists(source)) return;
|
||||||
|
final Path from = source;
|
||||||
|
final Path to = destination;
|
||||||
|
Files.walkFileTree(source, new SimpleFileVisitor<>() {
|
||||||
|
@Override public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
|
||||||
|
Files.createDirectories(to.resolve(from.relativize(dir))); return FileVisitResult.CONTINUE;
|
||||||
|
}
|
||||||
|
@Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
|
||||||
|
if (!attrs.isRegularFile() || Files.isSymbolicLink(file)) throw new IOException("Home symlinks and special files require explicit migration");
|
||||||
|
Files.copy(file, to.resolve(from.relativize(file)), StandardCopyOption.COPY_ATTRIBUTES);
|
||||||
|
return FileVisitResult.CONTINUE;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void transition(Map<String, String> op, String state, String message) {
|
||||||
|
op.put("state", state); op.put("message", message); op.put("updatedAt", Instant.now().toString());
|
||||||
|
store.saveOperation(op.get("id"), op);
|
||||||
|
}
|
||||||
|
private static String digest(String text) {
|
||||||
|
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8))); }
|
||||||
|
catch (Exception error) { throw new IllegalStateException(error); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,208 +1,63 @@
|
|||||||
---
|
---
|
||||||
title: DeepSeek Harness Integration
|
title: DeepSeek Harness Integration
|
||||||
description: Install DeepSeek Harness and configure it as a digital employee runtime in MateClaw.
|
description: Configure, verify, upgrade, and recover the managed DeepSeek Harness SDK runtime.
|
||||||
head:
|
|
||||||
- - meta
|
|
||||||
- name: keywords
|
|
||||||
content: DeepSeek Harness,DSH,digital employee,Agent runtime,JSON-RPC,Cordis
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# DeepSeek Harness Integration
|
# DeepSeek Harness Integration
|
||||||
|
|
||||||
This guide connects the official DeepSeek Harness (DSH) to MateClaw and creates a digital employee powered by the DSH runtime.
|
DSH is an employee runtime. MateClaw launches the official `dsh --profile sdk` process and translates its JSON-RPC session events into conversation output. Configure the DeepSeek provider in **Settings → Models**, then select **DSH / DeepSeek Harness** when creating a digital employee with a workspace.
|
||||||
|
|
||||||
In MateClaw, DSH is an **employee runtime**, not an MCP tool and not a regular plugin. MCP supplies tools; DSH owns the external Agent process, the ReAct loop, and the event stream that MateClaw projects into the conversation UI.
|
## Requirements and installation
|
||||||
|
|
||||||
## Architecture
|
The managed installer requires **Node.js 22**, npm, and registry access. It supports macOS arm64 and Linux x64 candidates. Actual package installation and Java adapter/model checks have passed on macOS arm64 with Node 22.20.0 and npm 11.6.2. Linux x64 remains pending actual verification; Windows/WSL has not been verified.
|
||||||
|
|
||||||
```text
|
Use the DSH management console to install the pinned **`0.2.0-rc.1`** target. The reviewed alternative is **`0.1.7-rc.2`**. Installation uses checked-in dependency locks and `npm ci` in a separate candidate directory; it does not resolve a moving `latest` tag or modify global npm packages. Both are prereleases.
|
||||||
MateClaw Chat / SSE
|
|
||||||
|
|
|
||||||
v
|
|
||||||
DSH Runtime Provider
|
|
||||||
|
|
|
||||||
v JSON-RPC over stdin/stdout
|
|
||||||
dsh-jsonrpc-agent
|
|
||||||
|
|
|
||||||
v
|
|
||||||
DeepSeek API + Cordis composition
|
|
||||||
```
|
|
||||||
|
|
||||||
MateClaw remains responsible for employees, sessions, permissions, workspaces, message persistence, and UI projection. DSH runs the turn. MateClaw injects the API key from the DeepSeek provider configuration into the DSH child process. Never put secrets in `runtimeConfig`, employee prompts, or the repository.
|
Run **Verify**, **Test connection**, and **Test task**. These mean different things: configuration checks, an SDK `initialize` handshake, and a real model task requiring the answer `OK`. A successful handshake alone does not establish model access. The wire server version `0.0.1` is not the npm package version; managed provenance records the pinned package separately. An external executable remains unverified unless independently checked.
|
||||||
|
|
||||||
## Prerequisites
|
## SDK configuration
|
||||||
|
|
||||||
- macOS, Linux, or Windows (commands below use macOS / Linux syntax)
|
Use an absolute executable path to `node_modules/.bin/dsh`, profile `sdk`, an existing working directory, and a dedicated home root. Optional patch paths are an ordered JSON array of absolute files. MateClaw assigns a separate `DSH_HOME` for each workspace/employee pair and launches arguments directly, without a shell.
|
||||||
- JDK 21
|
|
||||||
- A running MateClaw backend and frontend
|
|
||||||
- A DeepSeek API key
|
|
||||||
- A built DSH JSON-RPC agent
|
|
||||||
- The Cordis configuration from the DSH checkout
|
|
||||||
|
|
||||||
Check the runtime files:
|
| Managed key | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `dsh.executable_path` | Absolute SDK CLI executable |
|
||||||
|
| `dsh.profile` | Must be `sdk` |
|
||||||
|
| `dsh.working_directory` | Default workspace directory |
|
||||||
|
| `dsh.home_root` | Root for isolated runtime homes |
|
||||||
|
| `dsh.patch_paths` | Ordered JSON array of patch files, for example `[]` |
|
||||||
|
|
||||||
```bash
|
Legacy `dsh-jsonrpc-agent`/Cordis entrypoint configuration requires migration. Clear `dsh.cordis_config_path` and the old `DSH_CORDIS_CONFIG` environment setting; replace the executable with the SDK CLI. Do not put a shell command or extra arguments in the executable field. `DSH_JSONRPC_AGENT` remains a compatibility environment name for an executable path, not an instruction to use the old binary.
|
||||||
"$DSH_JSONRPC_AGENT" --help
|
|
||||||
test -x "$DSH_JSONRPC_AGENT"
|
|
||||||
test -f "$DSH_CORDIS_CONFIG"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Install DSH
|
Keep credentials in the provider configuration or dedicated protected runtime settings. MateClaw passes them to the child environment. For the official DeepSeek host, the adapter maps its ordinary API root to the Messages API `/anthropic` route required by these packages. Arbitrary custom endpoints still need a compatible Messages API.
|
||||||
|
|
||||||
Follow the [official DeepSeek Harness repository](https://github.com/deepseek-ai/deepseek-harness) for the current build instructions. The build must provide these two paths:
|
MateClaw sends an explicit model output cap through `initialize.maxTokens`: the configured positive cap, defaulting to 4096, limited to half the known context window. This does not replace DSH's context management.
|
||||||
|
|
||||||
```text
|
## Upgrade and rollback
|
||||||
<dsh-root>/dist-exe/dsh-jsonrpc-agent-pkg-<platform>
|
|
||||||
<dsh-root>/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
Keep the DSH binary outside the MateClaw source tree. Configure its location with environment variables.
|
Managed upgrades prepare a separate package generation, block new DSH turns, and wait up to 60 seconds for current turns to drain. They copy runtime homes and configured patch files, check the candidate handshake and a new model task in each affected home, then atomically select the executable/configuration/home generation together. Original homes and the previous generation remain available. Failure before activation keeps the previous selection. Native turns are not part of the DSH admission gate.
|
||||||
|
|
||||||
## Configure the IDEA backend
|
The home copier rejects symlinks and special files instead of following them. Customized homes containing such files require explicit migration. Generation records and package locations are retained; active home contents remain mutable during normal use.
|
||||||
|
|
||||||
Open **Run | Edit Configurations...** in IDEA, select the MateClaw Spring Boot configuration, and add these variables under **Environment variables**:
|
Rollback is available for a completed operation while its candidate generation is still active. It copies and checks the retained previous home/configuration before switching. It does **not** merge sessions created after the upgrade or reverse database changes. Keep retained generations and backups until acceptance is complete.
|
||||||
|
|
||||||
```text
|
Candidate checks start new sessions. They do not prove that every historic DSH session can be reopened or migrated. Actual persisted **V3 → V4 history migration has not been verified**.
|
||||||
DSH_JSONRPC_AGENT=/absolute/path/to/dsh-jsonrpc-agent-pkg-macos-arm64
|
|
||||||
DSH_CORDIS_CONFIG=/absolute/path/to/cordis.yml
|
|
||||||
DSH_CWD=/absolute/path/to/mateclaw-workspace
|
|
||||||
```
|
|
||||||
|
|
||||||
Example:
|
DSH uses its profile-managed tools and does not provide MateClaw host approval integration. Member file isolation blocks this runtime. Rollback does not undo tool writes to workspace files or delete MateClaw chat records.
|
||||||
|
|
||||||
```text
|
## Conversations and diagnostics
|
||||||
DSH_JSONRPC_AGENT=/opt/deepseek-harness/dist-exe/dsh-jsonrpc-agent-pkg-macos-arm64
|
|
||||||
DSH_CORDIS_CONFIG=/opt/deepseek-harness/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml
|
|
||||||
DSH_CWD=/var/lib/mateclaw/workspace
|
|
||||||
```
|
|
||||||
|
|
||||||
`DSH_CWD` must be readable and writable by the backend process. Use absolute paths in the IDEA run configuration. Restart the backend after changing them; Spring Boot does not hot-reload process environment variables.
|
Each MateClaw turn uses a fresh SDK session ID. MateClaw replays bounded completed user/assistant text history; this is not restoration of DSH tool state. Do not reuse an existing persisted SDK ID as a fresh session. Completion is based on the SDK terminal event, not merely on an enqueue receipt or idle status.
|
||||||
|
|
||||||
## Configure the DeepSeek provider
|
The admin API is under `/api/v1/admin/dsh`: `GET /status`, `POST /verify`, `/test-connection`, `/test-task`, `/upgrades`, and `GET /upgrades/{id}`. Upgrade requests carry an exact `targetVersion`, the current `expectedRevision`, and an `idempotencyKey`; rollback is `POST /upgrades/{id}/rollback` with revision and idempotency key. Poll the operation record for results. Logs and status must not expose credentials.
|
||||||
|
|
||||||
1. Sign in to MateClaw.
|
## Recovering an orphaned runtime
|
||||||
2. Open **Settings → Models**.
|
|
||||||
3. Configure and enable the **DeepSeek** provider.
|
|
||||||
4. Enter the DeepSeek API key and base URL.
|
|
||||||
5. Confirm that at least one enabled DeepSeek chat model exists.
|
|
||||||
|
|
||||||
MateClaw resolves the model through its model configuration, using the global default when none is specified. If configuration resolution is unavailable, an explicitly requested model name is retained; `deepseek-v4-flash` is the fallback only when that name is also empty. Dedicated DSH credentials and endpoint settings take precedence; otherwise, MateClaw reads the selected model's provider configuration, falling back to the `deepseek` provider as needed. A custom model must be usable by the DeepSeek provider route in DSH.
|
`dsh.orphaned_runtime_requires_recovery` means a lease belongs to a JVM that is no longer alive. Admission deliberately remains blocked: its DSH process or tool descendants may still be writing the runtime home.
|
||||||
|
|
||||||
MateClaw explicitly sends the model's maximum output tokens through SDK `initialize.maxTokens`, avoiding DSH's 256000 default. An unset or invalid output cap defaults to 4096. The cap is also limited to half the known context window to reserve space for input. The model's configured window takes precedence over the global conversation window. For a 128000-token window, an 8192 output cap stays 8192; an oversized 256000 cap becomes 64000.
|
1. Stop DSH traffic and identify the lease under the managed install root's `leases/` directory (default root: `~/.mateclaw/runtimes/deepseek-harness`).
|
||||||
|
2. Verify the recorded JVM owner is gone, identify and stop its orphaned DSH processes **and tool descendants**, and confirm no process is writing the affected home.
|
||||||
|
3. Back up the affected state, then remove only the confirmed stale lease. Do not remove live leases or blindly delete the leases directory.
|
||||||
|
4. Inspect the durable upgrade operation and active generation, then retry verification before admitting work.
|
||||||
|
|
||||||
This is a static output bound, not a live token count of the complete DSH request. SDK initialization has no direct context-window field; DSH's internal context capacity and compaction remain controlled by its runtime/Cordis configuration. Long tool histories can still exceed the window. Upgrade older DSH versions that do not support `initialize.maxTokens`.
|
A backend restart alone is not orphan-process cleanup. Do not delete a stale lease merely to bypass the error.
|
||||||
|
|
||||||
## Create a DSH digital employee
|
|
||||||
|
|
||||||
Open **Digital Employees → New**:
|
|
||||||
|
|
||||||
1. Enter the employee name, role, and goal.
|
|
||||||
2. Select **DSH / DeepSeek Harness** as the runtime.
|
|
||||||
3. Set a workspace; when blank, `DSH_CWD` is used.
|
|
||||||
4. Use a JSON object for `runtimeConfig`, for example:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mode": "qa",
|
|
||||||
"workspace": "default",
|
|
||||||
"policy": "read-only"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
5. Save the employee and open its chat.
|
|
||||||
|
|
||||||
Runtime configuration describes employee policy only. Do not put `DEEPSEEK_API_KEY`, cookies, bearer tokens, or sensitive local paths in it.
|
|
||||||
|
|
||||||
## Verification checklist
|
|
||||||
|
|
||||||
Send this message in the DSH employee conversation:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Reply with exactly: DSH_RUNTIME_OK
|
|
||||||
```
|
|
||||||
|
|
||||||
Success means:
|
|
||||||
|
|
||||||
- The employee header shows `DSH Harness`.
|
|
||||||
- Thinking state and text deltas appear in the chat.
|
|
||||||
- The log contains `provider=deepseek` and `apiKeyConfigured=true`.
|
|
||||||
- The log contains a `turn/end` event with `kind=completed`.
|
|
||||||
- The UI does not show “no output for this run”.
|
|
||||||
- The same conversation is not used to start two different DSH live sessions.
|
|
||||||
|
|
||||||
You can continue chatting in the same MateClaw conversation. Each turn uses a fresh DSH process and runtime session ID to avoid persisted-log `id collision`. MateClaw supplies up to 40 recent completed user/assistant text messages from that conversation, within a 4096 estimated-token history budget. The current message is sent once and is not truncated by this history budget. Older history may be omitted and a boundary message may be marked `[truncated]`. Saved text history remains available after a backend restart; internal DSH tool state is not restored. Scheduled tasks do not replay conversation history.
|
|
||||||
|
|
||||||
To verify multi-turn context, send “My name is Alex”, then “What is my name?” in the same conversation. A new conversation must not inherit that information through this history mechanism.
|
|
||||||
|
|
||||||
## Logs and diagnostics
|
|
||||||
|
|
||||||
The backend log is commonly located at:
|
|
||||||
|
|
||||||
```text
|
|
||||||
logs/mateclaw.log
|
|
||||||
```
|
|
||||||
|
|
||||||
Search for the runtime signals:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rg "\[DSH\]|MISSING_CREDENTIAL|EMPTY_RESPONSE|id collision" logs/mateclaw.log
|
|
||||||
```
|
|
||||||
|
|
||||||
The admin-only diagnostics endpoint is:
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /api/v1/admin/agent-runtime/dsh/diagnostics
|
|
||||||
```
|
|
||||||
|
|
||||||
It returns command, executable, Cordis, and capability status without returning the API key.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### `MISSING_CREDENTIAL`
|
|
||||||
|
|
||||||
Check that:
|
|
||||||
|
|
||||||
1. The DeepSeek provider is enabled under Settings → Models.
|
|
||||||
2. The API key was saved successfully.
|
|
||||||
3. The employee uses DSH rather than configuring the DSH binary as an MCP command.
|
|
||||||
4. The backend was restarted with the updated IDEA configuration.
|
|
||||||
|
|
||||||
`apiKeyConfigured=false` in the log means the credential did not reach the DSH child process.
|
|
||||||
|
|
||||||
### `EMPTY_RESPONSE`
|
|
||||||
|
|
||||||
Check model availability and the base URL. Verify with a short fixed prompt before adding tools or skills.
|
|
||||||
|
|
||||||
### `dsh.command_unavailable`
|
|
||||||
|
|
||||||
`DSH_JSONRPC_AGENT` must point to an executable file:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
chmod +x /absolute/path/to/dsh-jsonrpc-agent-pkg-macos-arm64
|
|
||||||
```
|
|
||||||
|
|
||||||
### `dsh.cordis_missing`
|
|
||||||
|
|
||||||
`DSH_CORDIS_CONFIG` must point to the actual `cordis.yml`, not only the package directory. If a package directory is provided, MateClaw also checks its `runtime/cordis.yml` child path.
|
|
||||||
|
|
||||||
### The answer appears twice
|
|
||||||
|
|
||||||
Use the latest backend version. DSH emits text deltas followed by a final assistant snapshot. MateClaw must project the deltas only and must not append the snapshot again.
|
|
||||||
|
|
||||||
## MCP, plugins, and DSH
|
|
||||||
|
|
||||||
| Mechanism | Best for | Replaces DSH? |
|
|
||||||
|-----------|----------|--------------|
|
|
||||||
| MCP | File, GitHub, database, and other tools | No |
|
|
||||||
| Plugin | Extending MateClaw tools, models, channels, or memory | No |
|
|
||||||
| DSH employee runtime | Hosting the DeepSeek Harness Agent loop and process | It is the employee runtime, not a tool |
|
|
||||||
|
|
||||||
The recommended composition is: **DSH as the employee runtime, MCP as the tool layer, and MateClaw as the governance and visualization layer**.
|
|
||||||
|
|
||||||
## Security recommendations
|
|
||||||
|
|
||||||
- Store API keys only in MateClaw provider configuration or a controlled environment.
|
|
||||||
- Give DSH a dedicated workspace instead of the whole user home directory.
|
|
||||||
- Start with a read-only policy and the smallest possible tool set.
|
|
||||||
- Never commit `.sessions/`, logs, or local credential configuration.
|
|
||||||
- In production, restrict the DSH child process filesystem, network, and credential access.
|
|
||||||
|
|||||||
@ -1,208 +1,63 @@
|
|||||||
---
|
---
|
||||||
title: DeepSeek Harness 接入
|
title: DeepSeek Harness 集成
|
||||||
description: 在 MateClaw 中安装 DeepSeek Harness,并把它配置为数字员工运行时。
|
description: 配置、验证、升级和恢复受管理的 DeepSeek Harness SDK 运行时。
|
||||||
head:
|
|
||||||
- - meta
|
|
||||||
- name: keywords
|
|
||||||
content: DeepSeek Harness,DSH,数字员工,Agent runtime,JSON-RPC,Cordis
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# DeepSeek Harness 接入
|
# DeepSeek Harness 集成
|
||||||
|
|
||||||
本文说明如何把官方 DeepSeek Harness(简称 DSH)接入 MateClaw,并在员工页面创建一个由 DSH 驱动的数字员工。
|
DSH 是数字员工运行时。MateClaw 启动官方 `dsh --profile sdk` 进程,把 JSON-RPC 会话事件投影到聊天界面。先在 **设置 → 模型** 配置 DeepSeek 提供方,再为有工作空间的数字员工选择 **DSH / DeepSeek Harness**。
|
||||||
|
|
||||||
DSH 在 MateClaw 中是**员工运行时**,不是 MCP 工具,也不是普通插件。MCP 负责为员工提供工具;DSH 负责启动外部 Agent 进程、运行 ReAct 循环并把思考、工具和文本事件流回 MateClaw。
|
## 环境与安装
|
||||||
|
|
||||||
## 架构
|
受管理安装要求 **Node.js 22**、npm 和 npm 仓库网络访问。安装器接受 macOS arm64 与 Linux x64 候选环境。实际 npm 安装、Java 适配器握手和真实模型任务已在 macOS arm64、Node 22.20.0、npm 11.6.2 上通过;Linux x64 仍待实机验证,Windows/WSL 未验证。
|
||||||
|
|
||||||
```text
|
在 DSH 管理控制台安装固定目标 **`0.2.0-rc.1`**,另一个受审查版本为 **`0.1.7-rc.2`**。两者均为预发布版本。安装使用仓库内依赖锁文件,在独立候选目录运行 `npm ci`,不会追踪移动的 `latest` 标签,也不会修改全局 npm 包。
|
||||||
MateClaw Chat / SSE
|
|
||||||
|
|
|
||||||
v
|
|
||||||
DSH Runtime Provider
|
|
||||||
|
|
|
||||||
v JSON-RPC over stdin/stdout
|
|
||||||
dsh-jsonrpc-agent
|
|
||||||
|
|
|
||||||
v
|
|
||||||
DeepSeek API + Cordis composition
|
|
||||||
```
|
|
||||||
|
|
||||||
MateClaw 仍然负责员工、会话、权限、工作空间、消息持久化和 UI 投影。DSH 只负责运行时回合。API Key 由 MateClaw 的 DeepSeek 提供商配置注入到 DSH 子进程,不要把密钥写进 `runtimeConfig`、员工提示词或仓库文件。
|
依次运行 **校验**、**测试连接**、**测试任务**:分别检查配置、执行 SDK `initialize` 握手、调用真实模型并要求精确回答 `OK`。握手成功不等于模型可用。协议中的服务版本 `0.0.1` 不是 npm 包版本;受管理安装单独记录固定包的来源。外部可执行文件需要另行验证版本。
|
||||||
|
|
||||||
## 前置条件
|
## SDK 配置
|
||||||
|
|
||||||
- macOS、Linux 或 Windows(本文命令以 macOS / Linux 为例)
|
可执行文件必须是 `node_modules/.bin/dsh` 的绝对路径,profile 必须为 `sdk`,工作目录必须存在,并设置专用 home 根目录。可选 patch 列表是按顺序传入的绝对文件路径 JSON 数组。MateClaw 按工作空间/员工分配独立 `DSH_HOME`,直接传递参数,不经过 shell。
|
||||||
- JDK 21
|
|
||||||
- 已启动的 MateClaw 后端和前端
|
|
||||||
- DeepSeek API Key
|
|
||||||
- 已构建的 DSH JSON-RPC Agent
|
|
||||||
- DSH 仓库中的 Cordis 配置文件
|
|
||||||
|
|
||||||
确认 DSH 可执行文件:
|
| 配置键 | 用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| `dsh.executable_path` | SDK CLI 可执行文件绝对路径 |
|
||||||
|
| `dsh.profile` | 固定为 `sdk` |
|
||||||
|
| `dsh.working_directory` | 默认工作目录 |
|
||||||
|
| `dsh.home_root` | 隔离运行时 home 的根目录 |
|
||||||
|
| `dsh.patch_paths` | 有序 patch 路径 JSON 数组,例如 `[]` |
|
||||||
|
|
||||||
```bash
|
旧 `dsh-jsonrpc-agent`/Cordis 入口配置必须迁移。清空 `dsh.cordis_config_path`,移除旧 `DSH_CORDIS_CONFIG` 环境变量,改用 SDK CLI。可执行文件字段不能包含 shell 命令或附加参数。`DSH_JSONRPC_AGENT` 仍作为兼容环境变量名接受可执行文件路径,并不表示继续使用旧二进制。
|
||||||
"$DSH_JSONRPC_AGENT" --help
|
|
||||||
test -x "$DSH_JSONRPC_AGENT"
|
|
||||||
test -f "$DSH_CORDIS_CONFIG"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 安装 DSH
|
凭证保存在模型提供方或受保护的运行时配置中,由 MateClaw 注入子进程环境。对于 DeepSeek 官方域名,适配器将普通 API 根地址映射到这些包要求的 Messages API `/anthropic` 路径;其他自定义地址仍须提供兼容的 Messages API。
|
||||||
|
|
||||||
请以 [DeepSeek Harness 官方仓库](https://github.com/deepseek-ai/deepseek-harness) 的安装说明为准构建运行时。构建完成后,需要得到两个路径:
|
MateClaw 通过 `initialize.maxTokens` 显式设置输出上限:使用模型配置的正数值,缺省为 4096,并限制为已知上下文窗口的一半。这不替代 DSH 自身的上下文管理。
|
||||||
|
|
||||||
```text
|
## 升级与回滚
|
||||||
<dsh-root>/dist-exe/dsh-jsonrpc-agent-pkg-<platform>
|
|
||||||
<dsh-root>/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
不要把 DSH 二进制复制到 MateClaw 的源码仓库。推荐放在独立目录,并通过环境变量告诉 MateClaw 位置。
|
受管理升级先准备独立包版本,阻止新 DSH 回合进入,最多等待 60 秒让现有回合结束;随后复制运行时 home 和配置的 patch 文件,在各受影响 home 中执行候选握手与新模型任务,通过后原子切换可执行文件、配置及 home 所属代次。原 home 和旧代次保留;激活前失败继续使用旧选择。原生运行时不受 DSH 准入门控制。
|
||||||
|
|
||||||
## 在 IDEA 中配置后端
|
home 复制拒绝符号链接与特殊文件,不会跟随链接。包含这些文件的自定义 home 需要显式迁移。代次记录和包目录保留,但活动 home 在正常运行时仍会变化。
|
||||||
|
|
||||||
打开 IDEA 的 **Run | Edit Configurations...**,选择 MateClaw 的 Spring Boot 配置,在 **Environment variables** 中增加:
|
只有已完成操作的候选代次仍处于活动状态时,才能对该操作回滚。回滚复制并检查保留的旧 home/配置后再切换,不会合并升级后产生的会话,也不会逆向修改数据库。验收完成前保留旧代次和备份。
|
||||||
|
|
||||||
```text
|
候选检查创建新会话,不能证明每个历史 DSH 会话均可重新打开或迁移。**真实持久化 V3 → V4 历史迁移尚未验证。**
|
||||||
DSH_JSONRPC_AGENT=/absolute/path/to/dsh-jsonrpc-agent-pkg-macos-arm64
|
|
||||||
DSH_CORDIS_CONFIG=/absolute/path/to/cordis.yml
|
|
||||||
DSH_CWD=/absolute/path/to/mateclaw-workspace
|
|
||||||
```
|
|
||||||
|
|
||||||
示例:
|
DSH 使用 profile 管理的工具,不接入 MateClaw 宿主审批;启用成员文件隔离时禁止运行此运行时。回滚不会撤销工具对工作目录文件的写入,也不会删除 MateClaw 聊天记录。
|
||||||
|
|
||||||
```text
|
## 会话与诊断
|
||||||
DSH_JSONRPC_AGENT=/opt/deepseek-harness/dist-exe/dsh-jsonrpc-agent-pkg-macos-arm64
|
|
||||||
DSH_CORDIS_CONFIG=/opt/deepseek-harness/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml
|
|
||||||
DSH_CWD=/var/lib/mateclaw/workspace
|
|
||||||
```
|
|
||||||
|
|
||||||
`DSH_CWD` 必须是后端进程可读写的目录。IDEA 启动配置中的路径必须是绝对路径;修改后需要重启后端,Spring Boot 不会热加载环境变量。
|
每个 MateClaw 回合使用新的 SDK 会话 ID,并回放有界的已完成用户/助手文本历史;这不是恢复 DSH 工具状态。不要用已存在的持久化 SDK ID 创建新会话。是否完成由 SDK 终止事件决定,不能只依赖入队回执或 idle 状态。
|
||||||
|
|
||||||
## 配置 DeepSeek 提供商
|
管理 API 前缀为 `/api/v1/admin/dsh`:`GET /status`、`POST /verify`、`/test-connection`、`/test-task`、`/upgrades`,以及 `GET /upgrades/{id}`。升级请求包含精确 `targetVersion`、当前 `expectedRevision`、`idempotencyKey`;回滚接口为 `POST /upgrades/{id}/rollback`,携带版本修订号与幂等键。通过操作记录查询进度。日志及状态不得输出凭证。
|
||||||
|
|
||||||
1. 登录 MateClaw。
|
## 孤儿运行时恢复
|
||||||
2. 打开 **设置 → 模型**。
|
|
||||||
3. 配置并启用 **DeepSeek** 提供商。
|
|
||||||
4. 填入 DeepSeek API Key 和 Base URL。
|
|
||||||
5. 确认至少有一个启用的 DeepSeek chat 模型。
|
|
||||||
|
|
||||||
模型名称通过 MateClaw 模型配置解析,未指定时使用全局默认模型;未解析到模型配置时保留明确指定的模型名,连模型名也为空时才兜底为 `deepseek-v4-flash`。凭证和地址优先使用 DSH 专有设置,否则读取所选模型的提供商配置,必要时回退到 `deepseek` 提供商。自定义模型必须能由 DeepSeek Harness 的 DeepSeek provider route 使用。
|
`dsh.orphaned_runtime_requires_recovery` 表示某个租约所属 JVM 已退出。系统继续拒绝准入,因为遗留 DSH 进程或工具子孙进程仍可能写入 home。
|
||||||
|
|
||||||
MateClaw 会通过 DSH SDK 的 `initialize.maxTokens` 显式传递模型最大输出 token 数,避免继承 DSH 的 256000 默认值。未配置或配置无效时使用 4096,并限制为已知上下文窗口的一半,给输入预留空间。上下文窗口优先取模型的配置,未设置时取全局会话窗口配置。例如窗口为 128000、最大输出为 8192 时发送 8192;最大输出误设为 256000 时发送 64000。
|
1. 停止 DSH 流量,定位受管理安装根目录下 `leases/` 中的租约;默认根目录为 `~/.mateclaw/runtimes/deepseek-harness`。
|
||||||
|
2. 确认记录的 JVM 已退出,识别并停止其孤儿 DSH 进程及所有工具子孙进程,确认受影响 home 已无写入进程。
|
||||||
|
3. 备份状态后,仅删除已确认过期的租约。不要删除活动租约或直接清空租约目录。
|
||||||
|
4. 检查持久化升级操作与活动代次,重新验证后再恢复任务。
|
||||||
|
|
||||||
这是输出上限的静态保护,不是对完整 DSH 请求的实时 token 计数。SDK 初始化协议没有直接设置上下文窗口的字段;DSH 内部上下文容量和压缩仍由其运行时/Cordis 配置管理。长工具调用历史仍可能超出窗口。若使用不支持 `initialize.maxTokens` 的旧版 DSH,请升级到支持该字段的版本。
|
仅重启后端不能完成孤儿进程清理。不要为绕过错误直接删除租约。
|
||||||
|
|
||||||
## 创建 DSH 数字员工
|
|
||||||
|
|
||||||
在 **数字员工 → 新建** 中:
|
|
||||||
|
|
||||||
1. 填写员工名称、角色和目标。
|
|
||||||
2. 将运行时选择为 **DSH / DeepSeek Harness**。
|
|
||||||
3. 配置工作空间;留空时使用 `DSH_CWD`。
|
|
||||||
4. `runtimeConfig` 使用 JSON 对象,例如:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mode": "qa",
|
|
||||||
"workspace": "default",
|
|
||||||
"policy": "read-only"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
5. 保存员工并进入聊天。
|
|
||||||
|
|
||||||
运行时配置只描述员工级策略。不要在其中写 `DEEPSEEK_API_KEY`、Cookie、Bearer Token 或本机敏感路径。
|
|
||||||
|
|
||||||
## 验证清单
|
|
||||||
|
|
||||||
在 DSH 员工会话中发送:
|
|
||||||
|
|
||||||
```text
|
|
||||||
请只回复:DSH_RUNTIME_OK
|
|
||||||
```
|
|
||||||
|
|
||||||
成功标准:
|
|
||||||
|
|
||||||
- 员工标题显示 `DSH Harness`。
|
|
||||||
- 输入框发送后能看到思考状态和文本流。
|
|
||||||
- 日志出现 `provider=deepseek` 和 `apiKeyConfigured=true`。
|
|
||||||
- 日志出现 `turn/end` 且 `kind=completed`。
|
|
||||||
- 页面不会显示“本次没有输出”。
|
|
||||||
- 同一个会话不会被并发启动两个 DSH live session。
|
|
||||||
|
|
||||||
可以在同一个 MateClaw 会话中继续聊天。每轮仍使用新的 DSH 进程和运行时会话 ID,避免持久化日志的 `id collision`。MateClaw 会补入当前会话最近最多 40 条已完成的用户/助手文本消息,历史部分限制在估算 4096 token 内。本轮消息只发送一次,不受该历史预算截断;较旧历史可能被省略,边界消息可能带有 `[truncated]` 标记。后端重启后仍可读取已保存的文本历史,但不会恢复 DSH 内部工具状态。定时任务不回放会话历史。
|
|
||||||
|
|
||||||
验证多轮上下文:在同一会话先发送“我的名字叫小明”,再问“我叫什么名字?”。新建会话不应通过这项历史机制获得前一会话的信息。
|
|
||||||
|
|
||||||
## 日志与诊断
|
|
||||||
|
|
||||||
后端日志通常位于:
|
|
||||||
|
|
||||||
```text
|
|
||||||
logs/mateclaw.log
|
|
||||||
```
|
|
||||||
|
|
||||||
重点搜索:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rg "\[DSH\]|MISSING_CREDENTIAL|EMPTY_RESPONSE|id collision" logs/mateclaw.log
|
|
||||||
```
|
|
||||||
|
|
||||||
安全诊断接口:
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /api/v1/admin/agent-runtime/dsh/diagnostics
|
|
||||||
```
|
|
||||||
|
|
||||||
它只返回命令、可执行文件、Cordis 文件和能力状态,不返回 API Key。
|
|
||||||
|
|
||||||
## 常见问题
|
|
||||||
|
|
||||||
### `MISSING_CREDENTIAL`
|
|
||||||
|
|
||||||
检查:
|
|
||||||
|
|
||||||
1. 设置 → 模型中的 DeepSeek 提供商是否已启用。
|
|
||||||
2. API Key 是否保存成功。
|
|
||||||
3. 员工是否使用 DSH,而不是把 DSH 二进制配置成 MCP command。
|
|
||||||
4. 后端是否使用了修改后的 IDEA 配置并完成重启。
|
|
||||||
|
|
||||||
日志中的 `apiKeyConfigured=false` 表示凭证没有进入 DSH 子进程。
|
|
||||||
|
|
||||||
### `EMPTY_RESPONSE`
|
|
||||||
|
|
||||||
检查模型是否可用、Base URL 是否正确,以及该模型是否支持当前请求。先使用固定短消息验证,再逐步增加工具或技能。
|
|
||||||
|
|
||||||
### `dsh.command_unavailable`
|
|
||||||
|
|
||||||
`DSH_JSONRPC_AGENT` 必须指向真实可执行文件。检查文件权限:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
chmod +x /absolute/path/to/dsh-jsonrpc-agent-pkg-macos-arm64
|
|
||||||
```
|
|
||||||
|
|
||||||
### `dsh.cordis_missing`
|
|
||||||
|
|
||||||
`DSH_CORDIS_CONFIG` 必须指向实际存在的 `cordis.yml`,不是 DSH 包目录。若传入包目录,MateClaw 会尝试解析其下的 `runtime/cordis.yml`。
|
|
||||||
|
|
||||||
### 页面显示重复回答
|
|
||||||
|
|
||||||
确保使用最新后端版本。DSH 会同时发送增量文本事件和最终消息快照,MateClaw 只应投影增量文本,不能把快照再次追加到回答中。
|
|
||||||
|
|
||||||
## MCP、插件和 DSH 的边界
|
|
||||||
|
|
||||||
| 机制 | 适合做什么 | 是否替代 DSH |
|
|
||||||
|------|------------|-------------|
|
|
||||||
| MCP | 提供文件、GitHub、数据库等工具 | 否 |
|
|
||||||
| 插件 | 扩展 MateClaw 的工具、模型、渠道或记忆能力 | 否 |
|
|
||||||
| DSH 员工运行时 | 承载 DeepSeek Harness 的 Agent 循环和外部进程 | 是员工运行时,不是工具 |
|
|
||||||
|
|
||||||
推荐组合是:**DSH 作为员工运行时,MCP 作为工具层,MateClaw 作为治理和可视化层**。
|
|
||||||
|
|
||||||
## 安全建议
|
|
||||||
|
|
||||||
- API Key 只放在 MateClaw 模型提供商配置或受控环境变量中。
|
|
||||||
- DSH 工作空间使用专用目录,不要直接指向整个用户主目录。
|
|
||||||
- 初次接入使用只读策略和最小工具集。
|
|
||||||
- 不要把 `.sessions/`、日志或配置文件提交到 Git。
|
|
||||||
- 生产环境中限制 DSH 子进程的文件、网络和凭证访问范围。
|
|
||||||
|
|||||||
8915
mateclaw-server/src/main/resources/dsh/locks/0.1.7-rc.2.json
Normal file
8915
mateclaw-server/src/main/resources/dsh/locks/0.1.7-rc.2.json
Normal file
File diff suppressed because it is too large
Load Diff
9124
mateclaw-server/src/main/resources/dsh/locks/0.2.0-rc.1.json
Normal file
9124
mateclaw-server/src/main/resources/dsh/locks/0.2.0-rc.1.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,52 @@
|
|||||||
|
package vip.mate.agent.runtime.dsh;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import vip.mate.agent.runtime.dsh.management.DshRuntimeConfiguration;
|
||||||
|
import java.nio.file.*;
|
||||||
|
import java.util.*;
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
class DshLaunchSpecTest {
|
||||||
|
@TempDir Path directory;
|
||||||
|
@Test void preservesExecutableSpacesPatchOrderAndIsolatesAgentHomes() throws Exception {
|
||||||
|
Path executable = Files.writeString(directory.resolve("dsh binary"), "#!/bin/sh\nexit 0\n");
|
||||||
|
executable.toFile().setExecutable(true);
|
||||||
|
Path first = Files.writeString(directory.resolve("first patch.yml"), "{}");
|
||||||
|
Path second = Files.writeString(directory.resolve("second.yml"), "{}");
|
||||||
|
var config = new DshRuntimeConfiguration(executable.toString(), "", directory.toString(), "", "", "",
|
||||||
|
"sdk", List.of(first.toString(), second.toString()), directory.resolve("homes").toString());
|
||||||
|
var launch = DshLaunchSpec.create(config, 1L, 2L, directory);
|
||||||
|
assertEquals(List.of(executable.toString(), "--profile", "sdk", "--patch", first.toString(), "--patch", second.toString()), launch.command());
|
||||||
|
assertNotEquals(launch.home(), DshLaunchSpec.create(config, 1L, 3L, directory).home());
|
||||||
|
assertEquals(launch.home().toString(), launch.processBuilder(Map.of()).environment().get("DSH_HOME"));
|
||||||
|
}
|
||||||
|
@Test void rejectsLegacyCommandAndCordis() {
|
||||||
|
var config = new DshRuntimeConfiguration("/bin/sh --stdio", "", directory.toString(), "", "", "");
|
||||||
|
assertTrue(assertThrows(IllegalArgumentException.class, () -> DshLaunchSpec.create(config, 1, 2, directory)).getMessage().startsWith("MIGRATION_REQUIRED"));
|
||||||
|
}
|
||||||
|
@Test void normalizesOnlyOfficialApiRoute() {
|
||||||
|
assertEquals("https://api.deepseek.com/anthropic", DshRuntimeService.normalizeBaseUrl("https://api.deepseek.com"));
|
||||||
|
assertEquals("https://api.deepseek.com/anthropic", DshRuntimeService.normalizeBaseUrl("https://api.deepseek.com/v1/"));
|
||||||
|
assertEquals("https://custom.example/v1", DshRuntimeService.normalizeBaseUrl("https://custom.example/v1"));
|
||||||
|
}
|
||||||
|
@Test void workspaceSymlinkIsRejectedBeforeCreatingAgentHomeOutsideRoot() throws Exception {
|
||||||
|
Path root = Files.createDirectory(directory.resolve("homes"));
|
||||||
|
Path outside = Files.createDirectory(directory.resolve("outside"));
|
||||||
|
var config = new DshRuntimeConfiguration("/bin/sh", "", directory.toString(), "", "", "", "sdk", List.of(), root.toString());
|
||||||
|
var launch = DshLaunchSpec.create(config, 1, 2, directory);
|
||||||
|
Files.createSymbolicLink(launch.home().getParent(), outside);
|
||||||
|
assertThrows(IOException.class, () -> launch.processBuilder(Map.of()));
|
||||||
|
assertFalse(Files.exists(outside.resolve(launch.home().getFileName())), "validation must precede all writes through the link");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void homeRootSymlinkIsRejectedWithoutWritingToItsTarget() throws Exception {
|
||||||
|
Path outside = Files.createDirectory(directory.resolve("outside"));
|
||||||
|
Path root = Files.createSymbolicLink(directory.resolve("homes"), outside);
|
||||||
|
var config = new DshRuntimeConfiguration("/bin/sh", "", directory.toString(), "", "", "", "sdk", List.of(), root.toString());
|
||||||
|
var launch = DshLaunchSpec.create(config, 1, 2, directory);
|
||||||
|
assertThrows(IOException.class, () -> launch.processBuilder(Map.of()));
|
||||||
|
try (var entries = Files.list(outside)) { assertEquals(0, entries.count()); }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
package vip.mate.agent.runtime.dsh;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.*;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.ValueSource;
|
||||||
|
import vip.mate.agent.runtime.contract.RuntimeEventType;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/** Captured npm release SDK calls on macOS arm64/Node 22.20, Messages API, 2026-09-29.
|
||||||
|
* Replay state signatures removed. These recordings do not validate Linux/WSL or tools. */
|
||||||
|
class DshRecordedProtocolTest {
|
||||||
|
@ParameterizedTest @ValueSource(strings = {"0.1.7-rc.2", "0.2.0-rc.1"})
|
||||||
|
void recordedModelReplyCorrelatesPreResponseReceiptAndIncludesCachedInput(String version) throws Exception {
|
||||||
|
var mapper = new ObjectMapper();
|
||||||
|
try (var input = getClass().getResourceAsStream("/dsh/" + version + "-sdk.jsonl")) {
|
||||||
|
assertNotNull(input);
|
||||||
|
var frames = new String(input.readAllBytes(), StandardCharsets.UTF_8).lines().map(line -> {
|
||||||
|
try { return mapper.readTree(line); } catch (Exception error) { throw new IllegalStateException(error); }
|
||||||
|
}).toList();
|
||||||
|
String root = frames.stream().filter(frame -> frame.path("params").has("sessionId")).findFirst().orElseThrow().path("params").path("sessionId").asText();
|
||||||
|
var turn = new DshSdkTurn(root, "2");
|
||||||
|
for (var frame : frames) turn.accept(frame);
|
||||||
|
assertTrue(turn.complete());
|
||||||
|
assertEquals(RuntimeEventType.COMPLETED, turn.terminalType());
|
||||||
|
assertEquals("SDK_OK", turn.answer());
|
||||||
|
assertEquals(5421, turn.inputTokens());
|
||||||
|
assertEquals(4, turn.outputTokens());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
package vip.mate.agent.runtime.dsh;
|
package vip.mate.agent.runtime.dsh;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@ -13,11 +14,9 @@ import vip.mate.llm.model.ModelConfigEntity;
|
|||||||
import vip.mate.llm.model.ModelProviderEntity;
|
import vip.mate.llm.model.ModelProviderEntity;
|
||||||
import vip.mate.llm.service.ModelConfigService;
|
import vip.mate.llm.service.ModelConfigService;
|
||||||
import vip.mate.llm.service.ModelProviderService;
|
import vip.mate.llm.service.ModelProviderService;
|
||||||
|
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
@ -47,15 +46,21 @@ class DshRuntimeModelLimitsTest {
|
|||||||
Files.writeString(script, """
|
Files.writeString(script, """
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
IFS= read -r initialize
|
IFS= read -r initialize
|
||||||
printf '%s\\n' "$initialize" > "$1"
|
printf '%s\\n' "$initialize" > initialize.json
|
||||||
printf '%s\\n' '{"jsonrpc":"2.0","id":"init-limit-test","result":{}}'
|
id=$(printf '%s' "$initialize" | sed -n 's/.*"id":"\\([^"]*\\)".*/\\1/p')
|
||||||
|
printf '{"jsonrpc":"2.0","id":"%s","result":{"serverInfo":{"name":"deepseek-harness-sdk-runtime"}}}\\n' "$id"
|
||||||
IFS= read -r prompt
|
IFS= read -r prompt
|
||||||
printf '%s\\n' '{"jsonrpc":"2.0","id":"prompt-limit-test","result":{}}'
|
session=$(printf '%s' "$prompt" | sed -n 's/.*"sessionId":"\\([^"]*\\)".*/\\1/p')
|
||||||
printf '%s\\n' '{"jsonrpc":"2.0","method":"session.status","params":{"status":"idle"}}'
|
printf '%s\\n' '{"jsonrpc":"2.0","id":"prompt-limit-test","result":{"messageId":"m1"}}'
|
||||||
|
printf '{"jsonrpc":"2.0","method":"session.event","params":{"sessionId":"%s","event":{"seq":1,"type":"agent/inbox/spliced","data":{"inserted":[{"id":"m1"}]}}}}\\n' "$session"
|
||||||
|
printf '{"jsonrpc":"2.0","method":"session.event","params":{"sessionId":"%s","event":{"seq":2,"type":"turn/end","data":{"reason":{"kind":"completed"}}}}}\\n' "$session"
|
||||||
|
printf '{"jsonrpc":"2.0","method":"session.status","params":{"sessionId":"%s","status":"idle"}}\\n' "$session"
|
||||||
|
IFS= read -r shutdown
|
||||||
""");
|
""");
|
||||||
|
assertTrue(script.toFile().setExecutable(true));
|
||||||
var config = mock(DshRuntimeConfigService.class);
|
var config = mock(DshRuntimeConfigService.class);
|
||||||
when(config.resolve()).thenReturn(new DshRuntimeConfiguration(
|
when(config.resolve()).thenReturn(new DshRuntimeConfiguration(
|
||||||
"/bin/sh \"" + script + "\" \"" + capture + "\"", "", temp.toString(), "", "", ""));
|
script.toString(), "", temp.toString(), "", "", "", "sdk", List.of(), temp.resolve("homes").toString()));
|
||||||
var models = mock(ModelConfigService.class);
|
var models = mock(ModelConfigService.class);
|
||||||
var model = new ModelConfigEntity();
|
var model = new ModelConfigEntity();
|
||||||
model.setModelName("custom-model");
|
model.setModelName("custom-model");
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
package vip.mate.agent.runtime.dsh;
|
package vip.mate.agent.runtime.dsh;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import org.junit.jupiter.api.Assertions;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import vip.mate.config.ConversationWindowProperties;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.mockito.Mockito;
|
import org.mockito.Mockito;
|
||||||
@ -13,14 +17,12 @@ import vip.mate.agent.runtime.dsh.management.DshRuntimeConfiguration;
|
|||||||
import vip.mate.llm.model.ModelProviderEntity;
|
import vip.mate.llm.model.ModelProviderEntity;
|
||||||
import vip.mate.llm.service.ModelConfigService;
|
import vip.mate.llm.service.ModelConfigService;
|
||||||
import vip.mate.llm.service.ModelProviderService;
|
import vip.mate.llm.service.ModelProviderService;
|
||||||
|
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
@ -82,23 +84,25 @@ class DshRuntimeServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void streamSubscriptionReturnsBeforeSynchronousDshReadLoopFinishes() {
|
void streamSubscriptionReturnsBeforeSynchronousDshReadLoopFinishes(@TempDir Path temp) throws Exception {
|
||||||
DshRuntimeService service = service("/bin/sh -c \"sleep 5\"");
|
Path script = Files.writeString(temp.resolve("synthetic-sdk"), "#!/bin/sh\nsleep 5\n");
|
||||||
|
assertTrue(script.toFile().setExecutable(true));
|
||||||
|
DshRuntimeService service = service(script.toString());
|
||||||
AgentEntity agent = new AgentEntity();
|
AgentEntity agent = new AgentEntity();
|
||||||
agent.setId(1L);
|
agent.setId(1L);
|
||||||
agent.setWorkspaceId(2L);
|
agent.setWorkspaceId(2L);
|
||||||
agent.setModelName("model");
|
agent.setModelName("model");
|
||||||
|
|
||||||
Disposable subscription = assertTimeout(Duration.ofSeconds(2), () ->
|
Disposable subscription = assertTimeout(Duration.ofSeconds(2), () ->
|
||||||
service.stream(agent, "hello", "conversation", "model").subscribe());
|
service.stream(agent, "hello", "conversation", "model").subscribe(delta -> {}, error -> {}));
|
||||||
assertFalse(subscription.isDisposed());
|
assertFalse(subscription.isDisposed());
|
||||||
subscription.dispose();
|
subscription.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void commandLineKeepsQuotedExecutablePathTogether() {
|
void oldCommandStringRequiresMigration() {
|
||||||
assertEquals(List.of("/opt/Deep Seek/dsh-jsonrpc-agent", "--stdio"),
|
DshRuntimeConfiguration configuration = new DshRuntimeConfiguration("/bin/sh --stdio", "", "/tmp", "", "", "");
|
||||||
DshRuntimeService.commandLine("\"/opt/Deep Seek/dsh-jsonrpc-agent\" --stdio"));
|
assertTrue(configuration.migrationRequired());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -123,7 +127,8 @@ class DshRuntimeServiceTest {
|
|||||||
assertEquals("/usr/bin", environment.get("PATH"));
|
assertEquals("/usr/bin", environment.get("PATH"));
|
||||||
assertEquals("/Users/tester", environment.get("HOME"));
|
assertEquals("/Users/tester", environment.get("HOME"));
|
||||||
assertEquals("/workspace/project", environment.get("DSH_CWD"));
|
assertEquals("/workspace/project", environment.get("DSH_CWD"));
|
||||||
assertEquals("/opt/dsh/cordis.yml", environment.get("DSH_CORDIS_CONFIG"));
|
assertFalse(environment.containsKey("DSH_CORDIS_CONFIG"));
|
||||||
|
assertTrue(environment.containsKey("DSH_HOME"));
|
||||||
assertEquals("configured-key", environment.get("DEEPSEEK_API_KEY"));
|
assertEquals("configured-key", environment.get("DEEPSEEK_API_KEY"));
|
||||||
assertEquals("https://configured.example/v1", environment.get("DEEPSEEK_BASE_URL"));
|
assertEquals("https://configured.example/v1", environment.get("DEEPSEEK_BASE_URL"));
|
||||||
assertFalse(environment.containsKey("AWS_SECRET_ACCESS_KEY"));
|
assertFalse(environment.containsKey("AWS_SECRET_ACCESS_KEY"));
|
||||||
@ -131,6 +136,42 @@ class DshRuntimeServiceTest {
|
|||||||
assertFalse(environment.containsValue("/stale/cordis.yml"));
|
assertFalse(environment.containsValue("/stale/cordis.yml"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void committedMessageUsesContentNotStream() throws Exception {
|
||||||
|
RuntimeEvent event = service().mapEvent("root", 1, new ObjectMapper().readTree("""
|
||||||
|
{"type":"assistant/message","data":{"message":{"content":[{"type":"text","text":"answer"}]},
|
||||||
|
"stream":[{"type":"text-delta","text":"duplicate"}]}}
|
||||||
|
"""));
|
||||||
|
Assertions.assertNotNull(event);
|
||||||
|
assertEquals("answer", event.text());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void incompleteTurnIsFailure() throws Exception {
|
||||||
|
RuntimeEvent event = service().mapEvent("root", 1, new ObjectMapper().readTree("""
|
||||||
|
{"type":"turn/end","data":{"reason":{"kind":"max-tokens"}}}
|
||||||
|
"""));
|
||||||
|
Assertions.assertNotNull(event);
|
||||||
|
assertEquals(RuntimeEventType.FAILED, event.type());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toolUsesSdkNameAndCallId() throws Exception {
|
||||||
|
RuntimeEvent event = service().mapEvent("root", 1, new ObjectMapper().readTree("""
|
||||||
|
{"type":"tool/start","data":{"name":"read_file","callId":"call-1"}}
|
||||||
|
"""));
|
||||||
|
assertEquals("read_file", event.data().get("toolName"));
|
||||||
|
assertEquals("call-1", event.data().get("callId"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toolResultPairsWithNestedMessageCallId() throws Exception {
|
||||||
|
RuntimeEvent event = service().mapEvent("root", 1, new ObjectMapper().readTree("""
|
||||||
|
{"type":"tool/result","data":{"message":{"role":"tool","toolCallId":"call-1","content":[]}}}
|
||||||
|
"""));
|
||||||
|
assertEquals("call-1", event.data().get("callId"));
|
||||||
|
}
|
||||||
|
|
||||||
private static DshRuntimeService service() {
|
private static DshRuntimeService service() {
|
||||||
return service("/bin/dsh");
|
return service("/bin/dsh");
|
||||||
}
|
}
|
||||||
@ -138,10 +179,10 @@ class DshRuntimeServiceTest {
|
|||||||
private static DshRuntimeService service(String executable) {
|
private static DshRuntimeService service(String executable) {
|
||||||
DshRuntimeConfigService config = Mockito.mock(DshRuntimeConfigService.class);
|
DshRuntimeConfigService config = Mockito.mock(DshRuntimeConfigService.class);
|
||||||
Mockito.when(config.resolve()).thenReturn(new DshRuntimeConfiguration(
|
Mockito.when(config.resolve()).thenReturn(new DshRuntimeConfiguration(
|
||||||
executable, "", "/tmp", "", "model", ""));
|
executable, "", "/tmp", "", "model", "", "sdk", List.of(), Path.of(executable).getParent().resolve("homes").toString()));
|
||||||
return new DshRuntimeService(new ObjectMapper(),
|
return new DshRuntimeService(new ObjectMapper(),
|
||||||
Mockito.mock(ModelConfigService.class),
|
Mockito.mock(ModelConfigService.class),
|
||||||
Mockito.mock(ModelProviderService.class), config, new vip.mate.config.ConversationWindowProperties());
|
Mockito.mock(ModelProviderService.class), config, new ConversationWindowProperties());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static RuntimeSession session(Path workingDirectory) {
|
private static RuntimeSession session(Path workingDirectory) {
|
||||||
|
|||||||
@ -0,0 +1,63 @@
|
|||||||
|
package vip.mate.agent.runtime.dsh;
|
||||||
|
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
import vip.mate.config.ConversationWindowProperties;
|
||||||
|
import vip.mate.llm.service.ModelConfigService;
|
||||||
|
import vip.mate.llm.service.ModelProviderService;
|
||||||
|
import vip.mate.workspace.core.service.MemberFileIsolation;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import vip.mate.agent.runtime.dsh.management.*;
|
||||||
|
import vip.mate.system.service.SystemSettingService;
|
||||||
|
import java.nio.file.*;
|
||||||
|
import java.util.*;
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/** Synthetic subprocess fixtures exercise host failure handling, not real DSH compatibility. */
|
||||||
|
class DshSdkProtocolTest {
|
||||||
|
@TempDir Path directory;
|
||||||
|
|
||||||
|
@Test void sleepingProcessIsNotSuccessfulHandshake() throws Exception {
|
||||||
|
Path executable = script("sleep 10");
|
||||||
|
var configuration = new DshRuntimeConfiguration(executable.toString(), "", directory.toString(), "", "model", "",
|
||||||
|
"sdk", List.of(), directory.resolve("homes").toString());
|
||||||
|
DshRuntimeService service = runtime(configuration);
|
||||||
|
ReflectionTestUtils.setField(service, "initializeTimeoutMs", 100L);
|
||||||
|
assertEquals(false, service.testConnection(configuration).get("success"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void legacyCordisIsMigrationRequiredEvenWhenFilesExist() throws Exception {
|
||||||
|
Path executable = script("exit 0");
|
||||||
|
Path cordis = Files.writeString(directory.resolve("cordis.yml"), "{}");
|
||||||
|
var config = mock(DshRuntimeConfigService.class);
|
||||||
|
when(config.resolve()).thenReturn(new DshRuntimeConfiguration(executable.toString(), cordis.toString(), directory.toString(), "", "model", ""));
|
||||||
|
var service = new DshManagementService(config, mock(DshArtifactInstaller.class), mock(SystemSettingService.class));
|
||||||
|
assertEquals("MIGRATION_REQUIRED", service.status().get("state"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void healthCheckRejectsMemberIsolationBeforeSpawn() throws Exception {
|
||||||
|
Path marker = directory.resolve("started");
|
||||||
|
Path executable = script("touch '" + marker + "'");
|
||||||
|
var configuration = new DshRuntimeConfiguration(executable.toString(), "", directory.toString(), "", "model", "");
|
||||||
|
try {
|
||||||
|
MemberFileIsolation.configure(origin -> origin);
|
||||||
|
assertEquals(false, runtime(configuration).testTask(configuration).get("success"));
|
||||||
|
assertFalse(Files.exists(marker));
|
||||||
|
} finally { MemberFileIsolation.configure(null); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private DshRuntimeService runtime(DshRuntimeConfiguration configuration) {
|
||||||
|
var config = mock(DshRuntimeConfigService.class);
|
||||||
|
when(config.resolve()).thenReturn(configuration);
|
||||||
|
return new DshRuntimeService(new ObjectMapper(), mock(ModelConfigService.class),
|
||||||
|
mock(ModelProviderService.class), config, new ConversationWindowProperties());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path script(String body) throws Exception {
|
||||||
|
Path file = Files.writeString(directory.resolve("synthetic dsh"), "#!/bin/sh\n" + body + "\n");
|
||||||
|
file.toFile().setExecutable(true);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
package vip.mate.agent.runtime.dsh;
|
||||||
|
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import java.nio.file.*;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Map;
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/** Synthetic executables test deterministic protocol failure paths, not release support. */
|
||||||
|
class DshSdkTransportTest {
|
||||||
|
@TempDir Path directory;
|
||||||
|
@Test void rejectsWebBannerWithoutLeakingToken() throws Exception {
|
||||||
|
Process process = process("echo 'dsh web: http://localhost/?token=secret123'; sleep 30");
|
||||||
|
try (var sdk = new DshSdkProcess(process, new ObjectMapper(), "secret123")) {
|
||||||
|
Exception error = assertThrows(Exception.class, () -> sdk.initialize(Map.of(), Duration.ofSeconds(3)));
|
||||||
|
assertTrue(error.getMessage().contains("WRONG_ENTRYPOINT"), error.getMessage());
|
||||||
|
assertFalse(error.getMessage().contains("secret123"));
|
||||||
|
}
|
||||||
|
assertFalse(process.isAlive());
|
||||||
|
}
|
||||||
|
@Test void timesOutAndReapsSilentProcess() throws Exception {
|
||||||
|
Process process = process("sleep 30");
|
||||||
|
try (var sdk = new DshSdkProcess(process, new ObjectMapper(), "")) {
|
||||||
|
Exception error = assertThrows(Exception.class, () -> sdk.initialize(Map.of(), Duration.ofMillis(100)));
|
||||||
|
assertEquals("DSH_TIMEOUT", error.getMessage());
|
||||||
|
}
|
||||||
|
assertTrue(process.waitFor(2, TimeUnit.SECONDS));
|
||||||
|
}
|
||||||
|
@Test void rejectsWrongResponseId() throws Exception {
|
||||||
|
Process process = process("read request; echo '{\"jsonrpc\":\"2.0\",\"id\":\"wrong\",\"result\":{}}'");
|
||||||
|
try (var sdk = new DshSdkProcess(process, new ObjectMapper(), "")) {
|
||||||
|
Exception error = assertThrows(Exception.class, () -> sdk.initialize(Map.of(), Duration.ofSeconds(1)));
|
||||||
|
assertEquals("DSH_HANDSHAKE_ID_MISMATCH", error.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Test void acceptsOnlySdkIdentityAndShutsDown() throws Exception {
|
||||||
|
Process process = process("""
|
||||||
|
read request
|
||||||
|
id=$(printf '%s' "$request" | sed -n 's/.*"id":"\\([^"]*\\)".*/\\1/p')
|
||||||
|
printf '{"jsonrpc":"2.0","id":"%s","result":{"serverInfo":{"name":"deepseek-harness-sdk-runtime","version":"0.0.1"}}}\\n' "$id"
|
||||||
|
read shutdown
|
||||||
|
""");
|
||||||
|
try (var sdk = new DshSdkProcess(process, new ObjectMapper(), "")) {
|
||||||
|
assertEquals("0.0.1", sdk.initialize(Map.of(), Duration.ofSeconds(2)).path("serverInfo").path("version").asText());
|
||||||
|
}
|
||||||
|
assertTrue(process.waitFor(2, TimeUnit.SECONDS));
|
||||||
|
}
|
||||||
|
@Test void redactsCredentialsAndUrlTokens() {
|
||||||
|
String diagnostic = DshSdkProcess.redact("api_key=abc https://host/?token=xyz bearer-secret", "bearer-secret");
|
||||||
|
assertFalse(diagnostic.contains("abc")); assertFalse(diagnostic.contains("xyz")); assertFalse(diagnostic.contains("bearer-secret"));
|
||||||
|
}
|
||||||
|
@Test void rejectsOversizedFrameBeforeParsingIt() throws Exception {
|
||||||
|
Process process = process("dd if=/dev/zero bs=1048576 count=17 2>/dev/null; sleep 30");
|
||||||
|
try (var sdk = new DshSdkProcess(process, new ObjectMapper(), "")) {
|
||||||
|
Exception error = assertThrows(Exception.class, () -> sdk.next(Duration.ofSeconds(5)));
|
||||||
|
assertEquals("DSH_FRAME_TOO_LARGE", error.getMessage());
|
||||||
|
}
|
||||||
|
assertFalse(process.isAlive());
|
||||||
|
}
|
||||||
|
@Test void stderrIsBoundedAndDoesNotBlockHandshakeTimeout() throws Exception {
|
||||||
|
Process process = process("dd if=/dev/zero bs=1024 count=128 1>&2 2>/dev/null; sleep 30");
|
||||||
|
try (var sdk = new DshSdkProcess(process, new ObjectMapper(), "")) {
|
||||||
|
assertThrows(Exception.class, () -> sdk.initialize(Map.of(), Duration.ofMillis(300)));
|
||||||
|
assertTrue(sdk.diagnostics().length() <= 65536);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private Process process(String body) throws Exception {
|
||||||
|
Path script = Files.writeString(directory.resolve("synthetic-runtime"), "#!/bin/sh\n" + body + "\n");
|
||||||
|
script.toFile().setExecutable(true);
|
||||||
|
return new ProcessBuilder(script.toString()).start();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
package vip.mate.agent.runtime.dsh;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.*;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import vip.mate.agent.runtime.contract.RuntimeEventType;
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
/** Synthetic envelopes matching pinned 0.2.0-rc.1 SDK shapes; no model compatibility claim. */
|
||||||
|
class DshSdkTurnTest {
|
||||||
|
private final ObjectMapper json = new ObjectMapper();
|
||||||
|
private JsonNode frame(String body) throws Exception { return json.readTree(body); }
|
||||||
|
@Test void buffersBeforePromptResponseAndRequiresRootReceiptThenTerminalThenIdle() throws Exception {
|
||||||
|
var turn = new DshSdkTurn("root", "prompt");
|
||||||
|
turn.accept(frame("""
|
||||||
|
{"method":"session.event","params":{"sessionId":"root","event":{"seq":1,"type":"agent/inbox/spliced","data":{"inserted":[{"id":"m1"}]}}}}"""));
|
||||||
|
turn.accept(frame("""
|
||||||
|
{"method":"session.status","params":{"sessionId":"child","status":"idle"}}"""));
|
||||||
|
turn.accept(frame("""
|
||||||
|
{"id":"prompt","result":{"messageId":"m1"}}"""));
|
||||||
|
assertFalse(turn.complete());
|
||||||
|
turn.accept(frame("""
|
||||||
|
{"method":"session.event","params":{"sessionId":"root","event":{"seq":2,"type":"turn/end","data":{"reason":{"kind":"completed"}}}}}"""));
|
||||||
|
assertFalse(turn.complete());
|
||||||
|
turn.accept(frame("""
|
||||||
|
{"method":"session.status","params":{"sessionId":"root","status":"idle"}}"""));
|
||||||
|
assertTrue(turn.complete());
|
||||||
|
assertEquals(RuntimeEventType.COMPLETED, turn.terminalType());
|
||||||
|
}
|
||||||
|
@Test void idleWithoutTerminalEvidenceFails() throws Exception {
|
||||||
|
var turn = new DshSdkTurn("root", "prompt");
|
||||||
|
turn.accept(frame("""
|
||||||
|
{"id":"prompt","result":{"messageId":"m1"}}"""));
|
||||||
|
turn.accept(frame("""
|
||||||
|
{"method":"session.event","params":{"sessionId":"root","event":{"seq":1,"type":"agent/inbox/spliced","data":{"inserted":[{"id":"m1"}]}}}}"""));
|
||||||
|
turn.accept(frame("""
|
||||||
|
{"method":"session.status","params":{"sessionId":"root","status":"idle"}}"""));
|
||||||
|
assertEquals(RuntimeEventType.FAILED, turn.terminalType());
|
||||||
|
}
|
||||||
|
@Test void deduplicatesCommittedUsageAndKeepsLastRootAnswer() throws Exception {
|
||||||
|
var turn = new DshSdkTurn("root", "prompt");
|
||||||
|
turn.accept(frame("""
|
||||||
|
{"id":"prompt","result":{"messageId":"m1"}}"""));
|
||||||
|
turn.accept(frame("""
|
||||||
|
{"method":"session.event","params":{"sessionId":"root","event":{"seq":1,"type":"agent/inbox/spliced","data":{"inserted":[{"id":"m1"}]}}}}"""));
|
||||||
|
var answer = frame("""
|
||||||
|
{"method":"session.event","params":{"sessionId":"root","event":{"seq":2,"type":"assistant/message","data":{"message":{"content":[{"type":"text","text":"answer"}]},"usage":{"inputTokens":3,"outputTokens":4}}}}}""");
|
||||||
|
turn.accept(answer); turn.accept(answer);
|
||||||
|
assertEquals(3, turn.inputTokens());
|
||||||
|
assertEquals(4, turn.outputTokens());
|
||||||
|
assertEquals("answer", turn.answer());
|
||||||
|
}
|
||||||
|
@Test void unknownRootEventCannotSilentlyChangeConversationMeaning() throws Exception {
|
||||||
|
var turn = new DshSdkTurn("root", "prompt");
|
||||||
|
turn.accept(frame("""
|
||||||
|
{"id":"prompt","result":{"messageId":"m1"}}"""));
|
||||||
|
turn.accept(frame("""
|
||||||
|
{"method":"session.event","params":{"sessionId":"root","event":{"seq":1,"type":"agent/inbox/spliced","data":{"inserted":[{"id":"m1"}]}}}}"""));
|
||||||
|
assertThrows(IllegalStateException.class, () -> turn.accept(frame("""
|
||||||
|
{"method":"session.event","params":{"sessionId":"root","event":{"seq":2,"type":"unknown/required","data":{}}}}""")));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,98 @@
|
|||||||
|
package vip.mate.agent.runtime.dsh.management;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import vip.mate.system.service.SettingCrypto;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class DshGenerationStoreTest {
|
||||||
|
@TempDir Path root;
|
||||||
|
|
||||||
|
private DshGenerationStore store() {
|
||||||
|
return new DshGenerationStore(root.toString(), new ObjectMapper(), new SettingCrypto("test-encryption-key"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void activationAndRollbackKeepVersionDataPairsAndEncryptCredentials() throws Exception {
|
||||||
|
DshGenerationStore store = store();
|
||||||
|
store.beginUpgrade("op1", store.revision());
|
||||||
|
store.activate("op1", "g1", Map.of("dsh.runtime_version", "0.1.7-rc.2", "dsh.home_root", root.resolve("g1/home").toString(), "dsh.api_key", "secret-value"));
|
||||||
|
store.finishUpgrade("op1");
|
||||||
|
String oldRevision = store.revision();
|
||||||
|
store.beginUpgrade("op2", oldRevision);
|
||||||
|
store.activate("op2", "g2", Map.of("dsh.runtime_version", "0.2.0-rc.1", "dsh.home_root", root.resolve("g2/home").toString(), "dsh.api_key", "new-secret"));
|
||||||
|
store.finishUpgrade("op2");
|
||||||
|
assertEquals("new-secret", store.activeValues().get("dsh.api_key"));
|
||||||
|
assertFalse(Files.readString(root.resolve("generations/g2.json")).contains("new-secret"));
|
||||||
|
store.beginUpgrade("rollback", store.revision());
|
||||||
|
store.restore("rollback", "g1");
|
||||||
|
store.finishUpgrade("rollback");
|
||||||
|
assertEquals("secret-value", store().activeValues().get("dsh.api_key"));
|
||||||
|
assertEquals("0.1.7-rc.2", store().activeValues().get("dsh.runtime_version"));
|
||||||
|
assertTrue(Files.exists(root.resolve("generations/g2.json")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void drainDoesNotKillActiveWorkAndAdmissionResumesAfterAbort() throws Exception {
|
||||||
|
DshGenerationStore store = store();
|
||||||
|
try (var lease = store.acquireLease("1", "2")) {
|
||||||
|
assertThrows(IllegalStateException.class, () -> store.acquireLease("1", "2"));
|
||||||
|
store.beginUpgrade("upgrade", store.revision());
|
||||||
|
assertThrows(IllegalStateException.class, () -> store.acquireLease("3", "4"));
|
||||||
|
assertFalse(store.awaitDrained(Duration.ofMillis(10)));
|
||||||
|
store.finishUpgrade("upgrade");
|
||||||
|
try (var second = store.acquireLease("3", "4")) { assertNotNull(second); }
|
||||||
|
}
|
||||||
|
assertTrue(store.awaitDrained(Duration.ofMillis(10)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void staleRevisionAndUnsafeGenerationCannotActivate() throws Exception {
|
||||||
|
DshGenerationStore store = store();
|
||||||
|
assertThrows(IllegalStateException.class, () -> store.beginUpgrade("op", "outdated"));
|
||||||
|
store.beginUpgrade("op", store.revision());
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> store.activate("op", "../escape", Map.of()));
|
||||||
|
assertTrue(store.activeValues().isEmpty());
|
||||||
|
store.finishUpgrade("op");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void activationCannotBypassDrainOrMutateDuringUpgrade() throws Exception {
|
||||||
|
DshGenerationStore store = store();
|
||||||
|
try (var lease = store.acquireLease("1", "2")) {
|
||||||
|
store.beginUpgrade("op", store.revision());
|
||||||
|
assertThrows(IllegalStateException.class, () -> store.activate("op", "g", Map.of()));
|
||||||
|
assertThrows(IllegalStateException.class, () -> store.updateActiveValues(Map.of("dsh.model_name", "changed")));
|
||||||
|
store.finishUpgrade("op");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Test
|
||||||
|
void deadJvmLeaseDoesNotAdmitPotentialOrphanWriters() throws Exception {
|
||||||
|
DshGenerationStore store = store();
|
||||||
|
store.revision();
|
||||||
|
Path leases = Files.createDirectories(root.resolve("leases"));
|
||||||
|
new ObjectMapper().writeValue(leases.resolve("orphan.json").toFile(), Map.of("pid", "0", "started", "", "home", "1:2"));
|
||||||
|
assertThrows(IllegalStateException.class, () -> store.acquireLease("1", "2"));
|
||||||
|
assertTrue(Files.exists(leases.resolve("orphan.json")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void committedCrashIsReconciledBeforeSubsequentConfigWrite() throws Exception {
|
||||||
|
DshGenerationStore store = store();
|
||||||
|
store.beginUpgrade("op", store.revision());
|
||||||
|
store.saveOperation("op", Map.of("pid", "0", "started", "", "state", "ACTIVATING", "candidateGeneration", "g"));
|
||||||
|
store.activate("op", "g", Map.of("dsh.model_name", "old"));
|
||||||
|
new ObjectMapper().writeValue(root.resolve("gate.json").toFile(), Map.of("pid", "0", "started", "", "operation", "op"));
|
||||||
|
store.updateActiveValues(Map.of("dsh.model_name", "new"));
|
||||||
|
assertEquals("COMPLETED", store.operation("op").get("state"));
|
||||||
|
assertEquals("new", store.activeValues().get("dsh.model_name"));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
package vip.mate.agent.runtime.dsh.management;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class DshPinnedInstallerTest {
|
||||||
|
@Test
|
||||||
|
void catalogRequiresExactKnownVersionAndCorrectHostPlatform() {
|
||||||
|
assertEquals("0.2.0-rc.1", DshPinnedInstaller.release("0.2.0-rc.1").version());
|
||||||
|
assertTrue(DshPinnedInstaller.release("0.1.7-rc.2").integrity().startsWith("sha512-"));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> DshPinnedInstaller.release("latest"));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> DshPinnedInstaller.release("1.0.0"));
|
||||||
|
assertEquals("linux-x64", DshPinnedInstaller.platform("Linux", "amd64"));
|
||||||
|
assertEquals("darwin-arm64", DshPinnedInstaller.platform("Mac OS X", "aarch64"));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> DshPinnedInstaller.platform("Windows", "amd64"));
|
||||||
|
}
|
||||||
|
@Test
|
||||||
|
void trustedLocksPinAllRegistryPackagesBeforeInstallScripts() throws Exception {
|
||||||
|
var mapper = new ObjectMapper();
|
||||||
|
for (var release : DshPinnedInstaller.catalog()) {
|
||||||
|
try (var input = getClass().getResourceAsStream("/dsh/locks/" + release.version() + ".json")) {
|
||||||
|
assertNotNull(input);
|
||||||
|
var packages = mapper.readTree(input).path("packages");
|
||||||
|
assertEquals(release.integrity(), packages.path("node_modules/@deepseek-ai/dsh").path("integrity").asText());
|
||||||
|
assertTrue(packages.size() > 100);
|
||||||
|
for (var entry : packages) {
|
||||||
|
if (entry.has("resolved")) {
|
||||||
|
assertTrue(entry.path("resolved").asText().startsWith("https://registry.npmjs.org/"));
|
||||||
|
assertTrue(entry.path("integrity").asText().startsWith("sha512-"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
package vip.mate.agent.runtime.dsh.management;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.ValueSource;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
import vip.mate.system.service.SettingCrypto;
|
||||||
|
import vip.mate.agent.runtime.dsh.DshRuntimeService;
|
||||||
|
import vip.mate.config.ConversationWindowProperties;
|
||||||
|
import vip.mate.llm.service.ModelConfigService;
|
||||||
|
import vip.mate.llm.service.ModelProviderService;
|
||||||
|
import vip.mate.system.service.SystemSettingService;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
class DshRuntimeConfigSaveTest {
|
||||||
|
@TempDir Path root;
|
||||||
|
|
||||||
|
@ParameterizedTest @ValueSource(booleans = {false, true})
|
||||||
|
void upgradeDuringConfigResolutionCannotRestoreStalePaths(boolean upgradeBeforeRead) {
|
||||||
|
AtomicBoolean interleave = new AtomicBoolean();
|
||||||
|
DshGenerationStore store = new DshGenerationStore(root.toString(), new ObjectMapper(), new SettingCrypto("test-key")) {
|
||||||
|
@Override public Map<String, String> activeValues() {
|
||||||
|
if (!interleave.compareAndSet(true, false)) return super.activeValues();
|
||||||
|
Map<String, String> before = super.activeValues();
|
||||||
|
beginUpgrade("upgrade", revision());
|
||||||
|
activate("upgrade", "new", Map.of("dsh.executable_path", "/new/dsh", "dsh.home_root", root.resolve("new").toString(), "dsh.runtime_version", "0.2.0-rc.1"));
|
||||||
|
finishUpgrade("upgrade");
|
||||||
|
return upgradeBeforeRead ? super.activeValues() : before;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
store.updateActiveValues(Map.of("dsh.executable_path", "/old/dsh", "dsh.home_root", root.resolve("old").toString(), "dsh.runtime_version", "0.1.7-rc.2"));
|
||||||
|
DshRuntimeConfigService config = config(store);
|
||||||
|
interleave.set(true);
|
||||||
|
IllegalStateException error = assertThrows(IllegalStateException.class, () -> config.save(Map.of("dsh.model_name", "edited")));
|
||||||
|
assertEquals("dsh.stale_configuration", error.getMessage());
|
||||||
|
assertEquals("/new/dsh", store.activeValues().get("dsh.executable_path"));
|
||||||
|
assertEquals(root.resolve("new").toString(), store.activeValues().get("dsh.home_root"));
|
||||||
|
assertEquals("0.2.0-rc.1", store.activeValues().get("dsh.runtime_version"));
|
||||||
|
assertFalse(store.activeValues().containsKey("dsh.model_name"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void malformedPatchJsonNeverBecomesActive() {
|
||||||
|
DshGenerationStore store = store();
|
||||||
|
store.updateActiveValues(Map.of("dsh.patch_paths", "[]"));
|
||||||
|
String revision = store.revision();
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> config(store).save(Map.of("dsh.patch_paths", "[")));
|
||||||
|
assertEquals(revision, store.revision());
|
||||||
|
assertEquals("[]", store.activeValues().get("dsh.patch_paths"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void validSubmissionCanRepairAlreadyInvalidStoredPatchJson() {
|
||||||
|
DshGenerationStore store = store();
|
||||||
|
store.updateActiveValues(Map.of("dsh.patch_paths", "["));
|
||||||
|
DshRuntimeConfigService config = config(store);
|
||||||
|
config.save(Map.of("dsh.patch_paths", "[]"));
|
||||||
|
assertTrue(config.resolve().patchPaths().isEmpty());
|
||||||
|
assertEquals("[]", store.activeValues().get("dsh.patch_paths"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void corruptRuntimeSettingsDoNotPreventAdminServiceStartup() {
|
||||||
|
DshGenerationStore store = store();
|
||||||
|
store.updateActiveValues(Map.of("dsh.patch_paths", "["));
|
||||||
|
assertDoesNotThrow(() -> new DshRuntimeService(new ObjectMapper(), mock(ModelConfigService.class),
|
||||||
|
mock(ModelProviderService.class), config(store), new ConversationWindowProperties()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private DshGenerationStore store() {
|
||||||
|
return new DshGenerationStore(root.toString(), new ObjectMapper(), new SettingCrypto("test-key"));
|
||||||
|
}
|
||||||
|
private DshRuntimeConfigService config(DshGenerationStore store) {
|
||||||
|
DshRuntimeConfigService service = new DshRuntimeConfigService(mock(SystemSettingService.class), "", "", root.toString(), "", "", "");
|
||||||
|
ReflectionTestUtils.setField(service, "generations", store);
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,108 @@
|
|||||||
|
package vip.mate.agent.runtime.dsh.management;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import vip.mate.agent.runtime.dsh.DshRuntimeService;
|
||||||
|
import vip.mate.system.service.SettingCrypto;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
class DshUpgradeServiceTest {
|
||||||
|
@TempDir Path root;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void failedCandidateRetainsActiveGenerationAndOriginalHome() throws Exception {
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
DshGenerationStore store = new DshGenerationStore(root.toString(), mapper, new SettingCrypto("test"));
|
||||||
|
Path home = Files.createDirectories(root.resolve("old-home"));
|
||||||
|
Files.writeString(home.resolve("user-state"), "preserve");
|
||||||
|
store.updateActiveValues(Map.of("dsh.home_root", home.toString(), "dsh.runtime_version", "0.1.7-rc.2"));
|
||||||
|
String previous = store.activeGeneration();
|
||||||
|
DshRuntimeConfigService config = mock(DshRuntimeConfigService.class);
|
||||||
|
when(config.resolve()).thenReturn(new DshRuntimeConfiguration("/bin/dsh", "", root.toString(), "", "model", ""));
|
||||||
|
DshRuntimeService runtime = mock(DshRuntimeService.class);
|
||||||
|
DshPinnedInstaller installer = mock(DshPinnedInstaller.class);
|
||||||
|
when(installer.install(eq("0.2.0-rc.1"), any())).thenThrow(new IllegalStateException("failure"));
|
||||||
|
DshUpgradeService service = new DshUpgradeService(store, config, runtime, installer, mapper, Runnable::run);
|
||||||
|
Map<String, String> op = service.upgrade("0.2.0-rc.1", store.revision(), "request-1");
|
||||||
|
assertEquals("FAILED", service.operation(op.get("id")).get("state"));
|
||||||
|
assertEquals(previous, store.activeGeneration());
|
||||||
|
assertEquals("preserve", Files.readString(home.resolve("user-state")));
|
||||||
|
try (var lease = store.acquireLease("w", "a")) { assertNotNull(lease); }
|
||||||
|
service.upgrade("0.2.0-rc.1", op.get("expectedRevision"), "request-1");
|
||||||
|
verify(installer, times(1)).install(eq("0.2.0-rc.1"), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void firstUpgradeFailedHealthDoesNotCreateAnActivePointer() throws Exception {
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
DshGenerationStore store = new DshGenerationStore(root.toString(), mapper, new SettingCrypto("test"));
|
||||||
|
Path home = Files.createDirectories(root.resolve("legacy-home"));
|
||||||
|
Files.writeString(home.resolve("history"), "preserve");
|
||||||
|
DshRuntimeConfigService config = mock(DshRuntimeConfigService.class);
|
||||||
|
when(config.resolve()).thenReturn(new DshRuntimeConfiguration("/bin/dsh", "", root.toString(), "", "model", "", "sdk", List.of(), home.toString()));
|
||||||
|
DshRuntimeService runtime = mock(DshRuntimeService.class);
|
||||||
|
when(runtime.testConnectionAtHome(any(), any())).thenReturn(Map.of("success", false));
|
||||||
|
DshPinnedInstaller installer = mock(DshPinnedInstaller.class);
|
||||||
|
when(installer.install(eq("0.2.0-rc.1"), any())).thenReturn(new DshPinnedInstaller.Installation("0.2.0-rc.1", "darwin-arm64", "/bin/dsh", "hash"));
|
||||||
|
DshUpgradeService service = new DshUpgradeService(store, config, runtime, installer, mapper, Runnable::run);
|
||||||
|
Map<String, String> op = service.upgrade("0.2.0-rc.1", "0", "first-failed");
|
||||||
|
assertEquals("FAILED", op.get("state"));
|
||||||
|
assertEquals("", store.activeGeneration());
|
||||||
|
assertEquals("0", store.revision());
|
||||||
|
assertEquals("preserve", Files.readString(home.resolve("history")));
|
||||||
|
try (var lease = store.acquireLease("w", "a")) { assertNotNull(lease); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void snapshotRejectsSymlinksWithoutFollowingOutsideRoot() throws Exception {
|
||||||
|
Path source = Files.createDirectories(root.resolve("source"));
|
||||||
|
Path outside = Files.writeString(root.resolve("private"), "private-content");
|
||||||
|
Files.createSymbolicLink(source.resolve("link"), outside);
|
||||||
|
assertThrows(Exception.class, () -> DshUpgradeService.copyHome(source, root.resolve("candidate")));
|
||||||
|
assertEquals("private-content", Files.readString(outside));
|
||||||
|
}
|
||||||
|
@Test
|
||||||
|
void candidateChecksEveryCopiedProfileBeforeActivation() throws Exception {
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
DshGenerationStore store = new DshGenerationStore(root.toString(), mapper, new SettingCrypto("test"));
|
||||||
|
Path home = Files.createDirectories(root.resolve("old-home"));
|
||||||
|
Files.createDirectories(home.resolve("w/a/profiles"));
|
||||||
|
Files.createDirectories(home.resolve("w/b/profiles"));
|
||||||
|
Files.writeString(home.resolve("w/a/state"), "original");
|
||||||
|
store.updateActiveValues(Map.of("dsh.home_root", home.toString(), "dsh.runtime_version", "0.1.7-rc.2"));
|
||||||
|
String previous = store.activeGeneration();
|
||||||
|
DshRuntimeConfigService config = mock(DshRuntimeConfigService.class);
|
||||||
|
when(config.resolve()).thenReturn(new DshRuntimeConfiguration("/bin/dsh", "", root.toString(), "", "model", ""));
|
||||||
|
DshRuntimeService runtime = mock(DshRuntimeService.class);
|
||||||
|
when(runtime.testConnectionAtHome(any(), any())).thenReturn(Map.of("success", true));
|
||||||
|
when(runtime.testTaskAtHome(any(), any())).thenAnswer(invocation -> {
|
||||||
|
Files.writeString(((Path) invocation.getArgument(1)).resolve("probe"), "candidate");
|
||||||
|
return Map.of("success", true);
|
||||||
|
});
|
||||||
|
DshPinnedInstaller installer = mock(DshPinnedInstaller.class);
|
||||||
|
when(installer.install(eq("0.2.0-rc.1"), any())).thenReturn(new DshPinnedInstaller.Installation("0.2.0-rc.1", "darwin-arm64", "/bin/dsh", "hash"));
|
||||||
|
DshUpgradeService service = new DshUpgradeService(store, config, runtime, installer, mapper, Runnable::run);
|
||||||
|
Map<String, String> op = service.upgrade("0.2.0-rc.1", store.revision(), "request-2");
|
||||||
|
assertEquals("COMPLETED", op.get("state"));
|
||||||
|
assertEquals("2", op.get("homesChecked"));
|
||||||
|
assertEquals("PASSED", op.get("taskStatus"));
|
||||||
|
assertNotEquals(previous, store.activeGeneration());
|
||||||
|
assertFalse(Files.exists(home.resolve("w/a/probe")));
|
||||||
|
verify(runtime, times(2)).testTaskAtHome(any(), any());
|
||||||
|
String rollbackRevision = store.revision();
|
||||||
|
Map<String, String> rollback = service.rollback(op.get("id"), rollbackRevision, "rollback-1");
|
||||||
|
assertEquals(rollback.get("id"), service.rollback(op.get("id"), rollbackRevision, "rollback-1").get("id"));
|
||||||
|
assertEquals("COMPLETED", rollback.get("state"));
|
||||||
|
assertEquals("0.1.7-rc.2", store.activeValues().get("dsh.runtime_version"));
|
||||||
|
assertEquals("original", Files.readString(home.resolve("w/a/state")));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
10
mateclaw-server/src/test/resources/dsh/0.1.7-rc.2-sdk.jsonl
Normal file
10
mateclaw-server/src/test/resources/dsh/0.1.7-rc.2-sdk.jsonl
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{"jsonrpc":"2.0","id":1,"result":{"serverInfo":{"name":"deepseek-harness-sdk-runtime","version":"0.0.1"}}}
|
||||||
|
{"jsonrpc":"2.0","method":"session.event","params":{"sessionId":"sdk-probe-1790672004227","event":{"type":"agent/inbox/spliced","seq":3,"time":1790672004234,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly SDK_OK. Do not use tools."}],"source":{"kind":"user"},"role":"user","id":"75a68f70-965c-4b4f-b1d6-c1191581d750"}]}}}}
|
||||||
|
{"jsonrpc":"2.0","method":"session.status","params":{"sessionId":"sdk-probe-1790672004227","status":"running"}}
|
||||||
|
{"jsonrpc":"2.0","method":"session.event","params":{"sessionId":"sdk-probe-1790672004227","event":{"type":"turn/start","seq":4,"time":1790672004235,"data":{"turn":1}}}}
|
||||||
|
{"jsonrpc":"2.0","method":"session.event","params":{"sessionId":"sdk-probe-1790672004227","event":{"type":"agent/inbox/spliced","seq":5,"time":1790672004235,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}}}
|
||||||
|
{"jsonrpc":"2.0","id":2,"result":{"messageId":"75a68f70-965c-4b4f-b1d6-c1191581d750"}}
|
||||||
|
{"jsonrpc":"2.0","method":"session.event","params":{"sessionId":"sdk-probe-1790672004227","event":{"type":"assistant/message","seq":14,"time":1790672004957,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":""},{"type":"text","text":"SDK_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e1a0eb37-fecc-447b-97a3-1f88784a5610"},"usage":{"inputTokens":5037,"outputTokens":4,"cacheReadTokens":384,"cacheWriteTokens":0,"totalTokens":5425},"stream":[{"type":"chunk","time":1790672004539,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"chunk","time":1790672004937,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":""}}},{"type":"chunk","time":1790672004937,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1790672004938,"index":1,"dt":[12,0],"texts":["SD","K","_OK"]},{"type":"chunk","time":1790672004954,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SDK_OK"}}},{"type":"chunk","time":1790672004955,"chunk":{"type":"usage","usage":{"inputTokens":5037,"outputTokens":4,"cacheReadTokens":384,"cacheWriteTokens":0,"totalTokens":5425}}},{"type":"chunk","time":1790672004955,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}}}
|
||||||
|
{"jsonrpc":"2.0","method":"session.event","params":{"sessionId":"sdk-probe-1790672004227","event":{"type":"turn/end","seq":16,"time":1790672004957,"data":{"turn":1,"reason":{"kind":"completed"}}}}}
|
||||||
|
{"jsonrpc":"2.0","method":"session.status","params":{"sessionId":"sdk-probe-1790672004227","status":"idle"}}
|
||||||
|
{"jsonrpc":"2.0","id":3,"result":{}}
|
||||||
10
mateclaw-server/src/test/resources/dsh/0.2.0-rc.1-sdk.jsonl
Normal file
10
mateclaw-server/src/test/resources/dsh/0.2.0-rc.1-sdk.jsonl
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{"jsonrpc":"2.0","id":1,"result":{"serverInfo":{"name":"deepseek-harness-sdk-runtime","version":"0.0.1"}}}
|
||||||
|
{"jsonrpc":"2.0","method":"session.event","params":{"sessionId":"sdk-probe-1790671995332","event":{"type":"agent/inbox/spliced","seq":3,"time":1790671995340,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly SDK_OK. Do not use tools."}],"source":{"kind":"user"},"role":"user","id":"5468f4e9-3fc1-4426-a5e3-f5fd4b152a52"}]}}}}
|
||||||
|
{"jsonrpc":"2.0","method":"session.status","params":{"sessionId":"sdk-probe-1790671995332","status":"running"}}
|
||||||
|
{"jsonrpc":"2.0","method":"session.event","params":{"sessionId":"sdk-probe-1790671995332","event":{"type":"turn/start","seq":4,"time":1790671995341,"data":{"turn":1}}}}
|
||||||
|
{"jsonrpc":"2.0","method":"session.event","params":{"sessionId":"sdk-probe-1790671995332","event":{"type":"agent/inbox/spliced","seq":5,"time":1790671995341,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}}}
|
||||||
|
{"jsonrpc":"2.0","id":2,"result":{"messageId":"5468f4e9-3fc1-4426-a5e3-f5fd4b152a52"}}
|
||||||
|
{"jsonrpc":"2.0","method":"session.event","params":{"sessionId":"sdk-probe-1790671995332","event":{"type":"assistant/message","seq":14,"time":1790671996238,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":""},{"type":"text","text":"SDK_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"154a1d19-80a8-49d7-8af9-7ac3ea79c4d9"},"usage":{"inputTokens":5421,"outputTokens":4,"cacheReadTokens":0,"cacheWriteTokens":0,"totalTokens":5425},"stream":[{"type":"chunk","time":1790671995814,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"chunk","time":1790671996221,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":""}}},{"type":"chunk","time":1790671996221,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1790671996221,"index":1,"dt":[12,1],"texts":["SD","K","_OK"]},{"type":"chunk","time":1790671996236,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SDK_OK"}}},{"type":"chunk","time":1790671996236,"chunk":{"type":"usage","usage":{"inputTokens":5421,"outputTokens":4,"cacheReadTokens":0,"cacheWriteTokens":0,"totalTokens":5425}}},{"type":"chunk","time":1790671996236,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}}}
|
||||||
|
{"jsonrpc":"2.0","method":"session.event","params":{"sessionId":"sdk-probe-1790671995332","event":{"type":"turn/end","seq":16,"time":1790671996239,"data":{"turn":1,"reason":{"kind":"completed"}}}}}
|
||||||
|
{"jsonrpc":"2.0","method":"session.status","params":{"sessionId":"sdk-probe-1790671995332","status":"idle"}}
|
||||||
|
{"jsonrpc":"2.0","id":3,"result":{}}
|
||||||
@ -771,7 +771,11 @@ export const dshApi = {
|
|||||||
saveConfig: (data: Record<string, string>) => http.put('/admin/dsh/config', data),
|
saveConfig: (data: Record<string, string>) => http.put('/admin/dsh/config', data),
|
||||||
install: () => http.post('/admin/dsh/install'),
|
install: () => http.post('/admin/dsh/install'),
|
||||||
verify: () => http.post('/admin/dsh/verify'),
|
verify: () => http.post('/admin/dsh/verify'),
|
||||||
testConnection: () => http.post('/admin/dsh/test-connection'),
|
testConnection: () => http.post('/admin/dsh/test-connection', undefined, { timeout: 45000 }),
|
||||||
|
testTask: () => http.post('/admin/dsh/test-task', undefined, { timeout: 100000 }),
|
||||||
|
upgrade: (data: { targetVersion: string; expectedRevision: string; idempotencyKey: string }) => http.post('/admin/dsh/upgrades', data),
|
||||||
|
upgradeStatus: (id: string) => http.get(`/admin/dsh/upgrades/${encodeURIComponent(id)}`),
|
||||||
|
rollback: (id: string, data: { expectedRevision: string; idempotencyKey: string }) => http.post(`/admin/dsh/upgrades/${encodeURIComponent(id)}/rollback`, data),
|
||||||
enable: () => http.post('/admin/dsh/enable'),
|
enable: () => http.post('/admin/dsh/enable'),
|
||||||
disable: () => http.post('/admin/dsh/disable'),
|
disable: () => http.post('/admin/dsh/disable'),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,9 @@ describe('DSH managed configuration mapping', () => {
|
|||||||
'dsh.model_name': 'deepseek-chat',
|
'dsh.model_name': 'deepseek-chat',
|
||||||
'dsh.api_key': 'plain-test-key',
|
'dsh.api_key': 'plain-test-key',
|
||||||
})).toEqual({
|
})).toEqual({
|
||||||
|
profile: 'sdk',
|
||||||
|
patch_paths: '[]',
|
||||||
|
home_root: '',
|
||||||
executable_path: '/opt/dsh/bin',
|
executable_path: '/opt/dsh/bin',
|
||||||
cordis_config_path: '/opt/dsh/cordis.yml',
|
cordis_config_path: '/opt/dsh/cordis.yml',
|
||||||
working_directory: '/srv/workspace',
|
working_directory: '/srv/workspace',
|
||||||
@ -31,6 +34,9 @@ describe('DSH managed configuration mapping', () => {
|
|||||||
|
|
||||||
it('maps every settings form field to the canonical backend key', () => {
|
it('maps every settings form field to the canonical backend key', () => {
|
||||||
const payload = formToManagedConfig({
|
const payload = formToManagedConfig({
|
||||||
|
profile: 'sdk',
|
||||||
|
patch_paths: '["/tmp/my patch.yml"]',
|
||||||
|
home_root: '/tmp/dsh-home',
|
||||||
executable_path: '/opt/dsh/bin',
|
executable_path: '/opt/dsh/bin',
|
||||||
cordis_config_path: '/opt/dsh/cordis.yml',
|
cordis_config_path: '/opt/dsh/cordis.yml',
|
||||||
working_directory: '/srv/workspace',
|
working_directory: '/srv/workspace',
|
||||||
@ -40,6 +46,9 @@ describe('DSH managed configuration mapping', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(payload).toEqual({
|
expect(payload).toEqual({
|
||||||
|
'dsh.profile': 'sdk',
|
||||||
|
'dsh.patch_paths': '["/tmp/my patch.yml"]',
|
||||||
|
'dsh.home_root': '/tmp/dsh-home',
|
||||||
'dsh.executable_path': '/opt/dsh/bin',
|
'dsh.executable_path': '/opt/dsh/bin',
|
||||||
'dsh.cordis_config_path': '/opt/dsh/cordis.yml',
|
'dsh.cordis_config_path': '/opt/dsh/cordis.yml',
|
||||||
'dsh.working_directory': '/srv/workspace',
|
'dsh.working_directory': '/srv/workspace',
|
||||||
@ -50,3 +59,11 @@ describe('DSH managed configuration mapping', () => {
|
|||||||
expect(payload).not.toHaveProperty('executable_path')
|
expect(payload).not.toHaveProperty('executable_path')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('rejects patch lists that are not JSON arrays of absolute paths', () => {
|
||||||
|
const form = createEmptyDshConfigForm()
|
||||||
|
form.patch_paths = '["relative.yml"]'
|
||||||
|
expect(() => formToManagedConfig(form)).toThrow()
|
||||||
|
form.patch_paths = '{}'
|
||||||
|
expect(() => formToManagedConfig(form)).toThrow()
|
||||||
|
})
|
||||||
|
|||||||
@ -0,0 +1,25 @@
|
|||||||
|
import { afterEach, expect, it, vi } from 'vitest'
|
||||||
|
import { createApp, nextTick } from 'vue'
|
||||||
|
import DshSettings from '../index.vue'
|
||||||
|
|
||||||
|
const api = vi.hoisted(() => ({ status: vi.fn(), testConnection: vi.fn() }))
|
||||||
|
vi.mock('@/api', () => ({ dshApi: api }))
|
||||||
|
vi.mock('@/composables/useMcToast', () => ({ mcToast: { success: vi.fn(), error: vi.fn() } }))
|
||||||
|
let unmount: (() => void) | undefined
|
||||||
|
afterEach(() => { unmount?.(); document.body.innerHTML = ''; sessionStorage.clear(); vi.clearAllMocks() })
|
||||||
|
async function flush() { for (let i = 0; i < 10; i++) { await Promise.resolve(); await nextTick() } }
|
||||||
|
|
||||||
|
it('clears a previous successful handshake after a failed recheck', async () => {
|
||||||
|
const ready = { state: 'READY', installed: true, enabled: false, config: {}, managed: {}, handshake: { success: true } }
|
||||||
|
api.status.mockResolvedValueOnce({ data: ready }).mockResolvedValue({ data: { ...ready, handshake: { success: false } } })
|
||||||
|
api.testConnection.mockResolvedValue({ data: { success: false, message: 'SDK handshake failed' } })
|
||||||
|
const host = document.createElement('div'); document.body.append(host)
|
||||||
|
const app = createApp(DshSettings); app.mount(host); unmount = () => app.unmount()
|
||||||
|
await flush()
|
||||||
|
const button = (label: string) => [...host.querySelectorAll('button')].find(item => item.textContent === label)!
|
||||||
|
expect(button('启用 DSH').disabled).toBe(false)
|
||||||
|
button('测试 SDK 握手').click(); await flush()
|
||||||
|
expect(button('启用 DSH').disabled).toBe(true)
|
||||||
|
expect(host.querySelector('.error-card')?.textContent).toContain('SDK handshake failed')
|
||||||
|
expect(api.status).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
@ -1,4 +1,7 @@
|
|||||||
export interface DshConfigForm {
|
export interface DshConfigForm {
|
||||||
|
profile: string
|
||||||
|
patch_paths: string
|
||||||
|
home_root: string
|
||||||
executable_path: string
|
executable_path: string
|
||||||
cordis_config_path: string
|
cordis_config_path: string
|
||||||
working_directory: string
|
working_directory: string
|
||||||
@ -8,6 +11,9 @@ export interface DshConfigForm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MANAGED_KEYS = {
|
const MANAGED_KEYS = {
|
||||||
|
profile: 'dsh.profile',
|
||||||
|
patch_paths: 'dsh.patch_paths',
|
||||||
|
home_root: 'dsh.home_root',
|
||||||
executable_path: 'dsh.executable_path',
|
executable_path: 'dsh.executable_path',
|
||||||
cordis_config_path: 'dsh.cordis_config_path',
|
cordis_config_path: 'dsh.cordis_config_path',
|
||||||
working_directory: 'dsh.working_directory',
|
working_directory: 'dsh.working_directory',
|
||||||
@ -20,7 +26,9 @@ const FORM_FIELDS = Object.keys(MANAGED_KEYS) as Array<keyof DshConfigForm>
|
|||||||
|
|
||||||
export function createEmptyDshConfigForm(): DshConfigForm {
|
export function createEmptyDshConfigForm(): DshConfigForm {
|
||||||
return {
|
return {
|
||||||
executable_path: '',
|
profile: 'sdk',
|
||||||
|
patch_paths: '[]',
|
||||||
|
home_root: '', executable_path: '',
|
||||||
cordis_config_path: '',
|
cordis_config_path: '',
|
||||||
working_directory: '',
|
working_directory: '',
|
||||||
base_url: '',
|
base_url: '',
|
||||||
@ -34,13 +42,17 @@ export function managedConfigToForm(
|
|||||||
): DshConfigForm {
|
): DshConfigForm {
|
||||||
const form = createEmptyDshConfigForm()
|
const form = createEmptyDshConfigForm()
|
||||||
for (const field of FORM_FIELDS) {
|
for (const field of FORM_FIELDS) {
|
||||||
form[field] = managed[MANAGED_KEYS[field]] || ''
|
form[field] = managed[MANAGED_KEYS[field]] ?? form[field]
|
||||||
}
|
}
|
||||||
if (form.api_key.startsWith('****')) form.api_key = ''
|
if (form.api_key.startsWith('****')) form.api_key = ''
|
||||||
return form
|
return form
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formToManagedConfig(form: DshConfigForm): Record<string, string> {
|
export function formToManagedConfig(form: DshConfigForm): Record<string, string> {
|
||||||
|
const patches: unknown = JSON.parse(form.patch_paths || '[]')
|
||||||
|
if (!Array.isArray(patches) || patches.some(path => typeof path !== 'string' || !(/^(?:\/|[A-Za-z]:[\\/])/.test(path)))) {
|
||||||
|
throw new Error('Patch 必须是绝对路径组成的 JSON 数组')
|
||||||
|
}
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
FORM_FIELDS.map(field => [MANAGED_KEYS[field], form[field]]),
|
FORM_FIELDS.map(field => [MANAGED_KEYS[field], form[field]]),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="mc-page-kicker">运行时管理</div>
|
<div class="mc-page-kicker">运行时管理</div>
|
||||||
<h2 class="section-title">DeepSeek Harness</h2>
|
<h2 class="section-title">DeepSeek Harness</h2>
|
||||||
<p class="section-desc">在页面完成安装、配置、检测和启用,不再依赖 IDEA 的环境变量。</p>
|
<p class="section-desc">使用 SDK profile 接入。分别检查文件、SDK 握手与真实模型任务,按固定版本升级。</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="state-pill" :class="`state-${state.toLowerCase()}`">{{ stateLabel }}</span>
|
<span class="state-pill" :class="`state-${state.toLowerCase()}`">{{ stateLabel }}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -23,37 +23,50 @@
|
|||||||
<div class="settings-card">
|
<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="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">
|
<div class="form-grid">
|
||||||
<label><span>可执行文件</span><input v-model="form.executable_path" placeholder="/path/to/dsh-jsonrpc-agent" /></label>
|
<label><span>可执行文件</span><input v-model="form.executable_path" placeholder="/absolute/path/to/dsh" /></label>
|
||||||
<label><span>Cordis 配置</span><input v-model="form.cordis_config_path" placeholder="/path/to/cordis.yml" /></label>
|
<label><span>SDK Profile</span><input v-model="form.profile" readonly /></label>
|
||||||
|
<label><span>独立数据根目录</span><input v-model="form.home_root" placeholder="留空使用托管目录" /></label>
|
||||||
|
<label><span>可选 Patch 绝对路径(JSON 数组)</span><input v-model="form.patch_paths" placeholder='["/path/to/patch.yml"]' /></label>
|
||||||
|
<label v-if="form.cordis_config_path"><span>旧 Cordis 配置(迁移时清空,不会自动转为 Patch)</span><input v-model="form.cordis_config_path" /></label>
|
||||||
<label><span>工作目录</span><input v-model="form.working_directory" placeholder="/path/to/workspace" /></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>DeepSeek Base URL</span><input v-model="form.base_url" placeholder="https://api.deepseek.com/anthropic" /></label>
|
||||||
<label><span>模型</span><input v-model="form.model_name" placeholder="deepseek-v4-flash" /></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>
|
<label><span>API Key</span><input v-model="form.api_key" type="password" autocomplete="new-password" placeholder="留空表示保持当前值" /></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions"><button class="btn-primary" :disabled="busy" @click="save">保存配置</button><button class="btn-secondary" :disabled="busy" @click="verify">验证配置</button></div>
|
<div class="actions"><button class="btn-primary" :disabled="busy || upgrading" @click="save">保存配置</button><button class="btn-secondary" :disabled="busy || upgrading" @click="verify">验证配置</button></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-card install-card">
|
<div class="settings-card install-card">
|
||||||
<div class="card-heading"><div><h3>安装与连接</h3><p>安装包由服务端私有制品清单提供,前端不能注入任意下载地址。</p></div></div>
|
<div class="card-heading"><div><h3>版本升级与连接</h3><p>版本与依赖使用固定清单。升级会排空活动任务,在副本中验证后切换,并保留旧数据。</p></div></div>
|
||||||
|
<label>目标版本 <select v-model="targetVersion" :disabled="busy || upgrading"><option value="0.2.0-rc.1">0.2.0-rc.1 · 预发布</option><option value="0.1.7-rc.2">0.1.7-rc.2 · 预发布</option></select></label>
|
||||||
|
<p class="muted-note">当前版本:{{ status.activeVersion || '未知 / 外部安装' }}。候选版本需完成本机检测,不代表已通过所有平台验证。</p>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn-secondary" :disabled="busy || !status.artifactManifestConfigured" @click="install">安装 / 更新 DSH</button>
|
<button class="btn-secondary" :disabled="busy || upgrading" @click="install">安装 / 升级所选版本</button>
|
||||||
<button class="btn-secondary" :disabled="busy || !status.installed" @click="testConnection">测试进程</button>
|
<button class="btn-secondary" :disabled="busy || upgrading || !status.installed" @click="testConnection">测试 SDK 握手</button>
|
||||||
<button v-if="status.enabled" class="btn-danger" :disabled="busy" @click="disable">停用</button>
|
<button class="btn-secondary" :disabled="busy || upgrading || !status.installed" @click="testTask">测试真实模型</button>
|
||||||
|
<button v-if="status.enabled" class="btn-danger" :disabled="busy || upgrading" @click="disable">停用</button>
|
||||||
<button v-else class="btn-primary" :disabled="busy || !canEnable" @click="enable">启用 DSH</button>
|
<button v-else class="btn-primary" :disabled="busy || !canEnable" @click="enable">启用 DSH</button>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!status.artifactManifestConfigured" class="muted-note">当前未配置私有制品清单。可以继续使用已有安装路径或旧环境变量;要启用页面安装,请设置 DSH_MANIFEST_URL。</p>
|
<p class="muted-note">自动安装候选支持 Linux x64 / macOS arm64,要求 Node.js 22 和 npm。自定义地址必须支持 Messages API;官方根地址会转换为 /anthropic。真实模型测试和升级验证会调用已配置的模型。</p>
|
||||||
|
<div v-if="operation.id" class="muted-note" role="status">
|
||||||
|
<strong>{{ operation.state }}</strong> · {{ operation.message }}
|
||||||
|
<p v-if="operation.homesChecked">已检查 {{ operation.homesChecked }} 个数据目录 · SDK {{ operation.handshakeStatus }} · 模型 {{ operation.taskStatus }}</p>
|
||||||
|
<button v-if="operation.state === 'COMPLETED' && operation.previousGeneration" class="btn-secondary" :disabled="busy || upgrading" @click="rollback">回滚版本和数据</button>
|
||||||
|
</div>
|
||||||
|
<p class="muted-note">回滚会恢复旧 DSH 数据快照,保留新版本数据供核对;不会撤销工具写入工作目录的文件,也不会删除 MateClaw 聊天记录。</p>
|
||||||
|
<p class="muted-note">DSH 自带工具由其 profile 管理,不提供 MateClaw 宿主审批;启用成员文件隔离时禁止运行。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-card diagnostics-card">
|
<div class="settings-card diagnostics-card">
|
||||||
<div class="card-heading"><div><h3>检测结果</h3><p>敏感信息只显示是否已配置。</p></div></div>
|
<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>
|
<dl><div><dt>文件检查</dt><dd>{{ checkLabel(status.fileCheck) }}</dd></div><div><dt>SDK 握手</dt><dd>{{ checkLabel(status.handshake) }}</dd></div><div><dt>模型任务</dt><dd>{{ checkLabel(status.taskCheck) }}</dd></div><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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||||
import { dshApi } from '@/api'
|
import { dshApi } from '@/api'
|
||||||
import { mcToast } from '@/composables/useMcToast'
|
import { mcToast } from '@/composables/useMcToast'
|
||||||
import {
|
import {
|
||||||
@ -67,31 +80,79 @@ const busy = ref(false)
|
|||||||
const error = ref('')
|
const error = ref('')
|
||||||
const status = reactive<any>({ state: 'NOT_INSTALLED', installed: false, enabled: false, config: {}, artifactManifestConfigured: false })
|
const status = reactive<any>({ state: 'NOT_INSTALLED', installed: false, enabled: false, config: {}, artifactManifestConfigured: false })
|
||||||
const form = reactive(createEmptyDshConfigForm())
|
const form = reactive(createEmptyDshConfigForm())
|
||||||
|
const targetVersion = ref('0.2.0-rc.1')
|
||||||
|
const operation = reactive<Record<string, string>>({})
|
||||||
|
const upgrading = computed(() => !!operation.id && !['COMPLETED', 'FAILED', 'INTERRUPTED'].includes(operation.state || ''))
|
||||||
|
let pollTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let disposed = false
|
||||||
|
function checkLabel(check: any) { return typeof check?.success !== 'boolean' ? '尚未检测' : check.success ? '通过' : '未通过' }
|
||||||
const state = computed(() => String(status.state || 'NOT_INSTALLED'))
|
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 stateLabel = computed(() => ({ MIGRATION_REQUIRED: '需要迁移旧配置', UNVERIFIED_VERSION: '版本待验证', 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 canEnable = computed(() => status.handshake?.success === true && !['CONFIG_INVALID', 'MIGRATION_REQUIRED'].includes(state.value) && !upgrading.value)
|
||||||
const steps = computed(() => [
|
const steps = computed(() => [
|
||||||
{ id: 1, title: '安装', description: status.installed ? '运行时已发现' : '从私有制品源安装', done: status.installed, active: !status.installed },
|
{ id: 1, title: '安装', description: status.installed ? '运行时已发现' : '安装固定 SDK 版本', 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: 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: 3, title: '验证', description: canEnable.value ? 'SDK 握手已通过' : '完成 SDK 握手测试', done: canEnable.value, active: false },
|
||||||
{ id: 4, title: '启用', description: status.enabled ? 'DSH 已接管运行时' : '启用后新任务使用托管配置', done: status.enabled, active: canEnable.value && !status.enabled },
|
{ id: 4, title: '启用', description: status.enabled ? 'DSH 已接管运行时' : '启用后新任务使用托管配置', done: status.enabled, active: canEnable.value && !status.enabled },
|
||||||
])
|
])
|
||||||
|
|
||||||
function applyResponse(response: any) {
|
function applyResponse(response: any) {
|
||||||
const data = response?.data ?? response
|
const data = response?.data ?? response
|
||||||
Object.assign(status, data)
|
Object.assign(status, data)
|
||||||
Object.assign(form, managedConfigToForm(data?.managed || {}))
|
if (data?.managed) Object.assign(form, managedConfigToForm(data.managed))
|
||||||
}
|
}
|
||||||
|
|
||||||
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 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 } }
|
async function run(action: () => Promise<any>, message: string) {
|
||||||
|
busy.value = true; error.value = ''
|
||||||
|
try {
|
||||||
|
const response: any = await action()
|
||||||
|
const data = response?.data ?? response
|
||||||
|
if (data?.success === false) throw new Error(data.message || '检测未通过')
|
||||||
|
await load()
|
||||||
|
mcToast.success(message)
|
||||||
|
} catch (e: any) {
|
||||||
|
// A failed recheck must invalidate the previously displayed health evidence.
|
||||||
|
status.handshake = {}; status.taskCheck = {}
|
||||||
|
await load()
|
||||||
|
error.value = e?.message || '操作失败'
|
||||||
|
mcToast.error(error.value)
|
||||||
|
} finally { busy.value = false }
|
||||||
|
}
|
||||||
function save() { return run(() => dshApi.saveConfig(formToManagedConfig(form)), 'DSH 配置已保存') }
|
function save() { return run(() => dshApi.saveConfig(formToManagedConfig(form)), 'DSH 配置已保存') }
|
||||||
function verify() { return run(dshApi.verify, 'DSH 配置验证完成') }
|
function verify() { return run(dshApi.verify, 'DSH 配置验证完成') }
|
||||||
function install() { return run(dshApi.install, 'DSH 安装完成') }
|
async function install() {
|
||||||
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 进程测试通过') }
|
return startOperation(() => dshApi.upgrade({ targetVersion: targetVersion.value, expectedRevision: status.configRevision, idempotencyKey: crypto.randomUUID() }))
|
||||||
|
}
|
||||||
|
async function rollback() {
|
||||||
|
if (!window.confirm('恢复旧 DSH 数据快照?升级后的内部会话状态不会合并回旧版本;工作目录文件保持现状,新数据将保留。')) return
|
||||||
|
return startOperation(() => dshApi.rollback(operation.id, { expectedRevision: status.configRevision, idempotencyKey: crypto.randomUUID() }))
|
||||||
|
}
|
||||||
|
async function startOperation(action: () => Promise<any>) {
|
||||||
|
busy.value = true; error.value = ''
|
||||||
|
try {
|
||||||
|
const response: any = await action()
|
||||||
|
Object.assign(operation, response?.data ?? response)
|
||||||
|
sessionStorage.setItem('dsh-upgrade-operation', operation.id)
|
||||||
|
await pollOperation()
|
||||||
|
} catch (e: any) { error.value = e?.message || '升级操作失败' }
|
||||||
|
finally { busy.value = false }
|
||||||
|
}
|
||||||
|
async function pollOperation() {
|
||||||
|
if (disposed || !operation.id) return
|
||||||
|
try {
|
||||||
|
const response: any = await dshApi.upgradeStatus(operation.id)
|
||||||
|
Object.assign(operation, response?.data ?? response)
|
||||||
|
if (upgrading.value) pollTimer = setTimeout(pollOperation, 2000)
|
||||||
|
else { await load(); if (operation.state !== 'COMPLETED') error.value = operation.message || '升级未完成' }
|
||||||
|
} catch (e: any) { error.value = e?.message || '读取升级状态失败'; if (!disposed) pollTimer = setTimeout(pollOperation, 5000) }
|
||||||
|
}
|
||||||
|
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 SDK 握手失败'); return response }, 'DSH SDK 握手通过') }
|
||||||
|
function testTask() { return run(dshApi.testTask, 'DSH 模型任务通过') }
|
||||||
function enable() { return run(dshApi.enable, 'DSH 已启用') }
|
function enable() { return run(dshApi.enable, 'DSH 已启用') }
|
||||||
function disable() { return run(dshApi.disable, 'DSH 已停用') }
|
function disable() { return run(dshApi.disable, 'DSH 已停用') }
|
||||||
onMounted(load)
|
onMounted(async () => { await load(); const id = sessionStorage.getItem('dsh-upgrade-operation'); if (id) { operation.id = id; await pollOperation() } })
|
||||||
|
onUnmounted(() => { disposed = true; if (pollTimer) clearTimeout(pollTimer) })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user