mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-11-02 14:11:49 +08:00
feat: add pinned DSH candidate and harden runtime recovery
This commit is contained in:
parent
e233523205
commit
bdc5087f70
@ -102,7 +102,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.
|
||||
|
||||
### 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** 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)
|
||||
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. An opt-in `0.2.1-rc.2` candidate adds SDK compatibility updates; macOS arm64 installation, handshake and short real-model/tool checks passed, while default selection stays unchanged. 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+)
|
||||
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.
|
||||
|
||||
@ -102,7 +102,7 @@ MateClaw 的 **LLM Wiki** 把它消化成结构化页面,页面之间自己长
|
||||
你雇佣员工,不是开聊天框。每位有**角色**、**目标**、**背景故事**、运行时、像素艺术头像与专属配色——6 个内置模板(通用助手 · 产品助理 · 研究分析师 · 客服助理 · 数据分析师 · 代码审查员)开箱可用。即使更换执行引擎,员工身份和治理边界仍保持不变。
|
||||
|
||||
### Agent Runtime:Native 或 DSH(2.2.0+)
|
||||
`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)
|
||||
`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`)。新增可选 `0.2.1-rc.2` 候选,macOS arm64 安装、握手和短模型/工具任务已验证,默认选择保持不变。升级复制 home、检查候选并切换保留的代次;回滚恢复旧 home/配置,不合并升级后的新会话。实际包及真实模型适配器检查已在 macOS arm64 通过;Linux x64 和持久化 V3→V4 历史迁移仍待验证。[配置 DeepSeek Harness →](https://claw.mate.vip/docs/zh/deepseek-harness)
|
||||
|
||||
### 持久长任务:检查点、重启、继续(2.2.0+)
|
||||
Persistent Goal 把需要数小时的工作拆成有界、可恢复的执行段。数据库会保存目标清单、continuation 状态、attempt、冷却、lease,以及员工忙碌期间已经接收的用户输入。单后端实例重启后,supervisor 会先核对被中断的 attempt,读取持久检查点和已有产物,再调度下一段安全工作,不要求用户重新描述任务。
|
||||
|
||||
@ -43,12 +43,17 @@ public record DshLaunchSpec(List<String> command, Path home, Path cwd) {
|
||||
createHomeDirectory(home.getParent());
|
||||
createHomeDirectory(home);
|
||||
if (!home.toRealPath().startsWith(root.toRealPath())) throw new IOException("DSH_HOME_ESCAPE");
|
||||
Path agentsHome = home.resolve("agents");
|
||||
// New SDK versions also read shared ~/.agents independently of DSH_HOME.
|
||||
// Keep that instruction root inside this employee's retained generation.
|
||||
requireDirectoryOrMissing(agentsHome);
|
||||
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());
|
||||
builder.environment().put("DSH_AGENTS_HOME", agentsHome.toString());
|
||||
return builder;
|
||||
}
|
||||
private static void requireDirectoryOrMissing(Path path) throws IOException {
|
||||
|
||||
@ -19,6 +19,7 @@ public final class DshSdkProcess implements AutoCloseable {
|
||||
private final BlockingQueue<Object> frames = new ArrayBlockingQueue<>(4096);
|
||||
private final AtomicInteger pendingBytes = new AtomicInteger();
|
||||
private final String secret;
|
||||
private final Set<ProcessHandle> ownedDescendants = ConcurrentHashMap.newKeySet();
|
||||
private final StringBuilder stderr = new StringBuilder();
|
||||
private volatile boolean closed;
|
||||
private volatile boolean initialized;
|
||||
@ -29,6 +30,44 @@ public final class DshSdkProcess implements AutoCloseable {
|
||||
writer = new BufferedWriter(new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8));
|
||||
startReader("dsh-sdk-stdout", this::readFrames);
|
||||
startReader("dsh-sdk-stderr", this::readErrors);
|
||||
startReader("dsh-sdk-owned-processes", this::watchOwnedProcesses);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort ownership retention: reparented children disappear from a crashed root's tree.
|
||||
* This cannot contain children born/reparented entirely between observations, or deliberately
|
||||
* detached before observation. It is not a replacement for an OS sandbox/process supervisor.
|
||||
*/
|
||||
private void watchOwnedProcesses() {
|
||||
try {
|
||||
while (!closed && process.isAlive()) {
|
||||
try (var descendants = process.descendants()) {
|
||||
descendants.forEach(ownedDescendants::add);
|
||||
} catch (RuntimeException ignored) {
|
||||
// The root can exit during enumeration; retain previously observed ownership.
|
||||
}
|
||||
ownedDescendants.removeIf(handle -> !handle.isAlive());
|
||||
Thread.sleep(20);
|
||||
}
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
} finally {
|
||||
terminateOwnedDescendants();
|
||||
}
|
||||
}
|
||||
|
||||
private void terminateOwnedDescendants() {
|
||||
// A recorded shell may have spawned another child between snapshots.
|
||||
for (ProcessHandle handle : List.copyOf(ownedDescendants)) {
|
||||
if (handle.isAlive()) try (var descendants = handle.descendants()) {
|
||||
descendants.forEach(ownedDescendants::add);
|
||||
} catch (RuntimeException ignored) { }
|
||||
}
|
||||
for (ProcessHandle handle : ownedDescendants) {
|
||||
if (!handle.isAlive()) continue;
|
||||
handle.destroy();
|
||||
if (handle.isAlive()) handle.destroyForcibly();
|
||||
}
|
||||
}
|
||||
|
||||
private static void startReader(String name, Runnable action) {
|
||||
@ -133,6 +172,6 @@ public final class DshSdkProcess implements AutoCloseable {
|
||||
process.waitFor(5, TimeUnit.SECONDS);
|
||||
}
|
||||
} catch (Exception ignored) { }
|
||||
finally { closed = true; DshRuntimeService.cancelProcess(process); }
|
||||
finally { closed = true; terminateOwnedDescendants(); DshRuntimeService.cancelProcess(process); }
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@ 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.
|
||||
// Session vocabulary pinned to the reviewed 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",
|
||||
@ -16,6 +16,7 @@ public final class DshSdkTurn {
|
||||
"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",
|
||||
"job/registered", "job/settled", "working-directory/change",
|
||||
"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",
|
||||
|
||||
@ -21,6 +21,8 @@ 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.1-rc.2", new Release("0.2.1-rc.2", "candidate",
|
||||
"sha512-tvY+yhfKRifthbqPe/OK0AYxM5ZkvqfYq/jMYgJcEUxiRRL1Vwra3Ti5TB6AXgSWQdiPHYh/Vp6SJa9yHwMNuw=="),
|
||||
"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",
|
||||
@ -33,7 +35,7 @@ public class DshPinnedInstaller {
|
||||
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 List<Release> catalog() { return List.of(release("0.2.0-rc.1"), release("0.2.1-rc.2"), 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";
|
||||
|
||||
@ -61,3 +61,21 @@ The admin API is under `/api/v1/admin/dsh`: `GET /status`, `POST /verify`, `/tes
|
||||
4. Inspect the durable upgrade operation and active generation, then retry verification before admitting work.
|
||||
|
||||
A backend restart alone is not orphan-process cleanup. Do not delete a stale lease merely to bypass the error.
|
||||
|
||||
## 0.2.1-rc.2 evolutionary candidate (2026-10-20)
|
||||
|
||||
The catalog now offers exact `0.2.1-rc.2` as an opt-in candidate alongside retained `0.2.0-rc.1` and `0.1.7-rc.2`. The default install target stays `0.2.0-rc.1`; adding the candidate does not change the active generation. All three versions are prereleases. The management page reads the server catalog and retains an installed catalog version rather than silently selecting a newer release.
|
||||
|
||||
Candidate checks on macOS arm64 / Node 22.20.0 / npm 11.6.2 cover clean `npm ci`, actual SDK initialize/shutdown, a DeepSeek V4 Flash reply (`SDK_OK`) and a real Bash tool task (`printf DSH_TOOL_OK`). Sanitized reply/tool recordings run through the Java turn adapter regression suite. Subsequent macOS checks also passed actual cancel/crash recovery, interrupted file-checkpoint verification and 20 fresh-process rounds in 501 seconds. They do not establish multi-hour endurance, persisted-history migration, Linux/WSL compatibility or production rollback for this candidate. Run the existing managed upgrade checks before activation; retain previous generations.
|
||||
|
||||
The adapter recognizes the candidate's job registration/settlement and working-directory metadata events without treating them as completion. `DSH_AGENTS_HOME` is explicitly scoped to `<employee-generation-home>/agents`, preventing implicit reads from the host user's `~/.agents`; a symlink at that instruction root is rejected. Administrator patch files can override SDK configuration and remain trusted inputs. This is not an OS sandbox, and DSH still has no MateClaw host tool-approval bridge.
|
||||
|
||||
Upstream model/search, minimal-profile image/compaction, interactive questions and working-directory APIs are separate integration work. Updating the package does not automatically expose them in MateClaw, merge DSH context management into native prompt budgets, or restore historic tool state. The existing text-history replay and output cap remain.
|
||||
|
||||
## Cancellation and workspace checkpoints (2026-10-20)
|
||||
|
||||
Real SDK/model checks exercised a running Bash sleep: explicit cancellation produced one CANCELLED event, and forcibly killing the SDK root failed the stream. In both tested cases the recorded SDK/tool PIDs exited and a new application connection completed. A separate interrupted-write case verified an already committed checkpoint without rewriting it. Twenty fresh-process rounds each read, append, wait and verify a ledger; all 20 exact ledger snapshots passed over 501 seconds. This is a bounded multi-minute exercise, not a multi-hour guarantee.
|
||||
|
||||
The transport now retains observed descendant ProcessHandles while the root is alive so those handles remain available after a crash. Cleanup is best effort: children born and reparented entirely between observations, or detached beforehand, can escape capture. An isolated runtime home does not provide OS containment, and stronger process supervision remains an open gate. Candidate status and strict-member-isolation restrictions remain.
|
||||
|
||||
Workspace-checkpoint recovery requires inspecting the existing artifact before deciding whether to repeat a write. It does not restore SDK tool state, automatically replay uncertain side effects, or prove exactly-once behavior for external systems. Goal acceptance, call budgets, approvals and application permissions are unchanged; regression covers them separately from these direct runtime probes.
|
||||
|
||||
@ -61,3 +61,21 @@ DSH 使用 profile 管理的工具,不接入 MateClaw 宿主审批;启用成
|
||||
4. 检查持久化升级操作与活动代次,重新验证后再恢复任务。
|
||||
|
||||
仅重启后端不能完成孤儿进程清理。不要为绕过错误直接删除租约。
|
||||
|
||||
## 0.2.1-rc.2 演进候选(2026-10-20)
|
||||
|
||||
版本目录新增精确的 `0.2.1-rc.2` 可选候选,保留 `0.2.0-rc.1` 和 `0.1.7-rc.2`。默认安装目标仍为 `0.2.0-rc.1`,增加候选不会切换当前 active generation。三个版本均为预发布。管理页从服务端读取版本目录,保留已安装且仍在目录内的选择,不自动选中新版本。
|
||||
|
||||
macOS arm64 / Node 22.20.0 / npm 11.6.2 上的候选检查覆盖干净 `npm ci`、真实 SDK initialize/shutdown、DeepSeek V4 Flash 短回答(`SDK_OK`)和一次真实 Bash 工具任务(`printf DSH_TOOL_OK`)。去敏的回答/工具记录已进入 Java 回合适配器回归。后续 macOS 验证还通过了真实取消/异常退出恢复、中断后文件检查点核验,以及 20 轮独立进程测试(501 秒)。这不代表此候选已完成多小时持续执行、持久历史迁移、Linux/WSL 或生产回滚验收。激活前仍需执行已有受管理升级检查,并保留旧 generation。
|
||||
|
||||
适配器识别候选的任务注册/结算和工作目录元数据事件,不将其视为完成。显式设置 `DSH_AGENTS_HOME=<员工 generation home>/agents`,避免隐式读取宿主用户的 `~/.agents`;指令根目录为符号链接时拒绝启动。管理员 patch 文件仍可覆盖 SDK 配置,属于受信任输入。这不是 OS 沙箱,DSH 仍未接入 MateClaw 宿主工具审批桥。
|
||||
|
||||
上游新增模型/搜索、极简 profile 的图片与压缩、交互提问及工作目录 API,需要分别适配;更新 npm 包不会自动在 MateClaw 中开放它们,也不会将 DSH 上下文管理合并到 Native 提示词预算,或恢复历史工具状态。现有文本历史回放和输出上限继续保留。
|
||||
|
||||
## 取消与工作目录检查点(2026-10-20)
|
||||
|
||||
真实 SDK/模型测试在 Bash sleep 执行期间发起取消,得到一次 CANCELLED;强制终止 SDK 根进程则使流失败。两个实测场景中记录到的 SDK/工具 PID 均退出,新建应用连接也可完成任务。另一次中断写入测试只核验已提交的检查点,没有重复写入。20 轮独立进程逐轮读取、追加、等待、回读台账,20 份精确文件快照全部通过,历时 501 秒。这是有界的分钟级验证,不是多小时运行保证。
|
||||
|
||||
传输层在根进程存活时保留观察到的后代 ProcessHandle,根崩溃后仍可据此清理。该机制是 best effort:在观察间隙内出生并被重新挂接、或提前脱离进程树的后代可能漏捕。独立 home 不等于 OS containment,更强的进程监督仍是待完成验收;候选状态和严格成员隔离限制保持不变。
|
||||
|
||||
文件检查点恢复需要先核验已有产物,再决定是否重做写入;它不会恢复 SDK 工具状态、自动重放结果不确定的副作用,也不能证明外部系统的 exactly-once。Goal 验收、调用预算、审批和应用权限保持原状,其回归与这些直接运行时探针分别记录。
|
||||
|
||||
9880
mateclaw-server/src/main/resources/dsh/locks/0.2.1-rc.2.json
Normal file
9880
mateclaw-server/src/main/resources/dsh/locks/0.2.1-rc.2.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -49,4 +49,20 @@ class DshLaunchSpecTest {
|
||||
try (var entries = Files.list(outside)) { assertEquals(0, entries.count()); }
|
||||
}
|
||||
|
||||
|
||||
@Test void sharedAgentInstructionsStayInsideTheEmployeeHome() throws Exception {
|
||||
var config = new DshRuntimeConfiguration("/bin/sh", "", directory.toString(), "", "", "", "sdk", List.of(), directory.resolve("homes").toString());
|
||||
var launch = DshLaunchSpec.create(config, 1, 2, directory);
|
||||
var env = launch.processBuilder(Map.of("DSH_AGENTS_HOME", "/untrusted/shared")).environment();
|
||||
assertEquals(launch.home().resolve("agents").toString(), env.get("DSH_AGENTS_HOME"));
|
||||
}
|
||||
|
||||
@Test void sharedAgentInstructionsCannotEscapeThroughASymlink() throws Exception {
|
||||
var config = new DshRuntimeConfiguration("/bin/sh", "", directory.toString(), "", "", "", "sdk", List.of(), directory.resolve("homes").toString());
|
||||
var launch = DshLaunchSpec.create(config, 1, 2, directory);
|
||||
launch.processBuilder(Map.of());
|
||||
Files.createSymbolicLink(launch.home().resolve("agents"), directory);
|
||||
assertThrows(IOException.class, () -> launch.processBuilder(Map.of()));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,39 @@
|
||||
package vip.mate.agent.runtime.dsh;
|
||||
|
||||
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 java.util.concurrent.TimeUnit;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class DshOwnedProcessTreeTest {
|
||||
@TempDir Path directory;
|
||||
@Test void rootCrashCleansAnObservedChildHoldingTheSdkPipe() throws Exception {
|
||||
Path childPid=directory.resolve("child.pid");
|
||||
Path script=Files.writeString(directory.resolve("fixture.py"), """
|
||||
import subprocess,json,sys,time,pathlib
|
||||
request=json.loads(sys.stdin.readline())
|
||||
child=subprocess.Popen(['sleep','60'])
|
||||
pathlib.Path('child.pid').write_text(str(child.pid))
|
||||
print(json.dumps({'jsonrpc':'2.0','id':request['id'],'result':{'serverInfo':{'name':'deepseek-harness-sdk-runtime'}}}),flush=True)
|
||||
time.sleep(60)
|
||||
""");
|
||||
Process root=new ProcessBuilder("python3",script.toString()).directory(directory.toFile()).start();
|
||||
Process unrelated = new ProcessBuilder("sleep", "60").start();
|
||||
ProcessHandle child=null;
|
||||
try(var sdk=new DshSdkProcess(root,new ObjectMapper(),"")) {
|
||||
sdk.initialize(Map.of(),Duration.ofSeconds(5));
|
||||
child=ProcessHandle.of(Long.parseLong(Files.readString(childPid))).orElseThrow();
|
||||
Thread.sleep(200); // permit the ownership monitor to observe a live tool child
|
||||
root.destroyForcibly();assertTrue(root.waitFor(2,TimeUnit.SECONDS));
|
||||
ProcessHandle ownedChild=child;
|
||||
assertTimeoutPreemptively(Duration.ofSeconds(4),()->{
|
||||
while(ownedChild.isAlive())Thread.sleep(25);
|
||||
});
|
||||
assertTrue(unrelated.isAlive(), "a sibling process is not owned by the crashed SDK");
|
||||
} finally { if(child!=null && child.isAlive())child.destroyForcibly();if(root.isAlive())root.destroyForcibly();unrelated.destroyForcibly(); }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,135 @@
|
||||
package vip.mate.agent.runtime.dsh;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import vip.mate.agent.runtime.contract.*;
|
||||
import vip.mate.agent.runtime.dsh.management.*;
|
||||
import vip.mate.config.ConversationWindowProperties;
|
||||
import vip.mate.llm.model.*;
|
||||
import vip.mate.llm.service.*;
|
||||
import java.nio.file.*;
|
||||
import java.sql.*;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** Opt-in, real package/model checks. Reads an explicitly supplied H2 database read-only. */
|
||||
@EnabledIfEnvironmentVariable(named="MATECLAW_DSH_ACCEPTANCE_EXECUTABLE", matches=".+")
|
||||
@Timeout(value=15, unit=TimeUnit.MINUTES)
|
||||
class DshRealRuntimeAcceptanceTest {
|
||||
@TempDir Path temp;
|
||||
private DshRuntimeService service;
|
||||
private RuntimeSession session;
|
||||
private final ObjectMapper json = new ObjectMapper();
|
||||
private final List<ProcessHandle> owned = new CopyOnWriteArrayList<>();
|
||||
|
||||
@BeforeEach void setup() throws Exception {
|
||||
String key = System.getenv("DEEPSEEK_API_KEY");
|
||||
String url = System.getenv().getOrDefault("DEEPSEEK_BASE_URL", "https://api.deepseek.com/anthropic");
|
||||
String database = System.getenv("MATECLAW_DSH_ACCEPTANCE_DATABASE");
|
||||
if (database != null) {
|
||||
try (var c=DriverManager.getConnection("jdbc:h2:file:"+database+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;ACCESS_MODE_DATA=r;IFEXISTS=TRUE", "sa", "");
|
||||
var s=c.prepareStatement("select api_key,base_url from mate_model_provider where provider_id='deepseek'");var r=s.executeQuery()) {
|
||||
assertTrue(r.next(), "configured DeepSeek provider required"); key=r.getString(1);url=r.getString(2);
|
||||
}
|
||||
}
|
||||
assertTrue(key != null && !key.isBlank(), "real model credential required");
|
||||
var config = mock(DshRuntimeConfigService.class);
|
||||
when(config.resolve()).thenReturn(new DshRuntimeConfiguration(System.getenv("MATECLAW_DSH_ACCEPTANCE_EXECUTABLE"), "", temp.toString(), url, "deepseek-v4-flash", key, "sdk", List.of(), temp.resolve("homes").toString()));
|
||||
var models=mock(ModelConfigService.class);var model=new ModelConfigEntity();model.setModelName("deepseek-v4-flash");model.setMaxTokens(4096);model.setMaxInputTokens(128000);
|
||||
when(models.resolveModel(any())).thenReturn(model);
|
||||
service=new DshRuntimeService(json, models, mock(ModelProviderService.class), config, new ConversationWindowProperties());
|
||||
session=new RuntimeSession("acceptance", "acceptance-"+UUID.randomUUID(), 991L, 992L, "deepseek-v4-flash", temp, Map.of());
|
||||
}
|
||||
@AfterEach void cleanup() { owned.forEach(p->{if(p.isAlive())p.destroyForcibly();}); }
|
||||
private void record(String name, Object data) throws Exception {
|
||||
String report = System.getenv("MATECLAW_DSH_ACCEPTANCE_REPORT_DIR");
|
||||
if (report != null) { Path root=Path.of(report); Files.createDirectories(root); json.writerWithDefaultPrettyPrinter().writeValue(root.resolve(name+".json").toFile(),data); }
|
||||
}
|
||||
private AgentRuntimeConnection connection() { return service.start(session); }
|
||||
private List<ProcessHandle> processes() { return ProcessHandle.current().descendants().filter(p->p.info().commandLine().orElse("").contains("--profile sdk")).toList(); }
|
||||
private static void await(java.util.function.BooleanSupplier condition, Duration timeout) throws Exception {
|
||||
long end=System.nanoTime()+timeout.toNanos();while(!condition.getAsBoolean() && System.nanoTime()<end)Thread.sleep(50);
|
||||
assertTrue(condition.getAsBoolean(), "bounded condition did not become true");
|
||||
}
|
||||
private void blockedTurn(boolean crash) throws Exception {
|
||||
var events=new CopyOnWriteArrayList<RuntimeEvent>();var error=new AtomicReference<Throwable>();var done=new CountDownLatch(1);
|
||||
var c=connection();String file=crash?"crash.pid":"cancel.pid";
|
||||
var subscription=c.prompt("Use bash to execute exactly: echo $$ > "+file+"; sleep 90; printf UNEXPECTED. Run in the current directory only. Do not use background execution. After the command reply UNEXPECTED.").subscribe(events::add,e->{error.set(e);done.countDown();},done::countDown);
|
||||
try {
|
||||
await(()->Files.exists(temp.resolve(file)),Duration.ofSeconds(120));
|
||||
var roots=processes();assertEquals(1,roots.size(),"isolated SDK root required");
|
||||
owned.addAll(roots);roots.forEach(p->owned.addAll(p.descendants().toList()));
|
||||
assertTrue(owned.size()>1,"actual tool descendants must exist");
|
||||
if(crash) roots.getFirst().destroyForcibly();
|
||||
else assertTimeoutPreemptively(Duration.ofSeconds(10),()->c.cancel().block());
|
||||
assertTrue(done.await(15,TimeUnit.SECONDS),"stream must finish after termination");
|
||||
await(()->owned.stream().noneMatch(ProcessHandle::isAlive),Duration.ofSeconds(5));
|
||||
assertFalse(events.stream().anyMatch(e->e.type()==RuntimeEventType.COMPLETED));
|
||||
if(!crash){assertNull(error.get());assertEquals(1,events.stream().filter(e->e.type()==RuntimeEventType.CANCELLED).count());}
|
||||
else assertTrue(error.get()!=null || events.stream().anyMatch(e->e.type()==RuntimeEventType.FAILED));
|
||||
var recovered=connection().prompt("Return exactly the nine uppercase ASCII characters RECOVERED with no punctuation, whitespace, Markdown or explanation. Do not call tools.").collectList().block(Duration.ofSeconds(120));
|
||||
assertNotNull(recovered);assertEquals(1,recovered.stream().filter(e->e.type()==RuntimeEventType.COMPLETED).count());
|
||||
assertEquals("RECOVERED",recovered.stream().filter(e->e.type()==RuntimeEventType.ASSISTANT_DELTA).map(RuntimeEvent::text).reduce("",String::concat));
|
||||
record(crash?"crash":"cancel",Map.of("success",true,"ownedPids",owned.stream().map(ProcessHandle::pid).toList(),"allExited",owned.stream().noneMatch(ProcessHandle::isAlive),"events",events,"recoveryEvents",recovered));
|
||||
} finally { subscription.dispose();c.close(); }
|
||||
}
|
||||
@Test void explicitCancellationStopsRealToolTreeAndNextConnectionRecovers() throws Exception { blockedTurn(false); }
|
||||
@Test void unexpectedRootExitStopsRealToolTreeAndNextConnectionRecovers() throws Exception { blockedTurn(true); }
|
||||
|
||||
@Test void twentyFreshProcessRoundsRecoverFromWorkspaceCheckpoints() throws Exception {
|
||||
int dwell=Integer.parseInt(System.getenv().getOrDefault("MATECLAW_DSH_ACCEPTANCE_DWELL_SECONDS","15"));
|
||||
assertTrue(dwell>=5 && dwell<=30,"dwell must be between 5 and 30 seconds");
|
||||
int rounds=Integer.parseInt(System.getenv().getOrDefault("MATECLAW_DSH_ACCEPTANCE_ROUNDS","20"));
|
||||
assertEquals(20,rounds,"this acceptance scenario requires exactly twenty rounds");
|
||||
long start=System.nanoTime();
|
||||
for(int i=1;i<=rounds;i++) {
|
||||
// Recreate the application connection; each prompt already creates a fresh SDK process/session.
|
||||
var c=connection();
|
||||
try {
|
||||
String prompt="Continue the checkpoint task in the current directory. Use bash/python to read ledger.txt if it exists (empty if absent), require lines 1 through "+(i-1)+" exactly once, append only the next line "+i+", then sleep "+dwell+" seconds and read back/validate lines 1 through "+i+". Never overwrite prior lines or modify any other file. Reply exactly ROUND_"+i+"_OK after verification.";
|
||||
var events=c.prompt(prompt).collectList().block(Duration.ofSeconds(180));assertNotNull(events);
|
||||
assertEquals(1,events.stream().filter(e->e.type()==RuntimeEventType.COMPLETED).count(),"round "+i);
|
||||
assertTrue(events.stream().anyMatch(e->e.type()==RuntimeEventType.TOOL_FINISHED),"real tool receipt required");
|
||||
assertEquals("ROUND_"+i+"_OK",events.stream().filter(e->e.type()==RuntimeEventType.ASSISTANT_DELTA).map(RuntimeEvent::text).reduce("",String::concat));
|
||||
assertEquals(java.util.stream.IntStream.rangeClosed(1,i).mapToObj(String::valueOf).toList(),Files.readAllLines(temp.resolve("ledger.txt")));
|
||||
record("round-"+i,Map.of("round",i,"success",true,"ledger",Files.readAllLines(temp.resolve("ledger.txt")),"events",events));
|
||||
System.out.println("DSH_REAL_ROUND "+i+" passed");
|
||||
} finally {c.close();}
|
||||
}
|
||||
long elapsed=Duration.ofNanos(System.nanoTime()-start).toSeconds();
|
||||
assertTrue(elapsed >= (long)rounds*dwell,"the sustained run must include the configured dwell time");
|
||||
record("multiturn",Map.of("rounds",rounds,"dwellSeconds",dwell,"elapsedSeconds",elapsed,"success",true));
|
||||
System.out.println("DSH_REAL_MULTITURN rounds="+rounds+" elapsedSeconds="+elapsed);
|
||||
}
|
||||
|
||||
@Test void checkpointCommittedBeforeCrashIsVerifiedWithoutReplayingTheWrite() throws Exception {
|
||||
var c=connection();var done=new CountDownLatch(1);var failure=new AtomicReference<Throwable>();
|
||||
var events=new CopyOnWriteArrayList<RuntimeEvent>();
|
||||
var subscription=c.prompt("Use bash to run exactly: printf '1\\n' > recovery-ledger.txt; echo $$ > recovery.pid; sleep 90; printf UNEXPECTED. Use only the current directory and do not use background execution.")
|
||||
.subscribe(events::add,e->{failure.set(e);done.countDown();},done::countDown);
|
||||
try {
|
||||
await(()->Files.exists(temp.resolve("recovery.pid")),Duration.ofSeconds(120));
|
||||
assertEquals(List.of("1"),Files.readAllLines(temp.resolve("recovery-ledger.txt")));
|
||||
var roots=processes();assertEquals(1,roots.size());owned.addAll(roots);roots.forEach(p->owned.addAll(p.descendants().toList()));
|
||||
roots.getFirst().destroyForcibly();
|
||||
assertTrue(done.await(15,TimeUnit.SECONDS));assertNotNull(failure.get());
|
||||
await(()->owned.stream().noneMatch(ProcessHandle::isAlive),Duration.ofSeconds(5));
|
||||
byte[] before=Files.readAllBytes(temp.resolve("recovery-ledger.txt"));
|
||||
try(var recovery=connection()) {
|
||||
var resumed=recovery.prompt("Recover the interrupted file task. Use a tool to read recovery-ledger.txt in the current directory. The write is already committed if it contains exactly one line equal to 1; in that case do not append, overwrite or repeat the write. Verify it and return exactly the thirteen uppercase ASCII characters CHECKPOINT_OK, without punctuation, Markdown or explanation.")
|
||||
.collectList().block(Duration.ofSeconds(120));
|
||||
assertNotNull(resumed);assertTrue(resumed.stream().anyMatch(e->e.type()==RuntimeEventType.TOOL_FINISHED));
|
||||
assertEquals(1,resumed.stream().filter(e->e.type()==RuntimeEventType.COMPLETED).count());
|
||||
assertEquals("CHECKPOINT_OK",resumed.stream().filter(e->e.type()==RuntimeEventType.ASSISTANT_DELTA).map(RuntimeEvent::text).reduce("",String::concat));
|
||||
assertArrayEquals(before,Files.readAllBytes(temp.resolve("recovery-ledger.txt")));
|
||||
record("checkpoint-recovery",Map.of("success",true,"writeUnchanged",true,"events",events,"recoveryEvents",resumed));
|
||||
}
|
||||
} finally {subscription.dispose();c.close();}
|
||||
}
|
||||
}
|
||||
@ -2,15 +2,16 @@ package vip.mate.agent.runtime.dsh;
|
||||
|
||||
import com.fasterxml.jackson.databind.*;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
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.
|
||||
/** Captured npm release SDK calls on macOS arm64/Node 22.20, Messages API, 2026-09-29 and 2026-10-20.
|
||||
* 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"})
|
||||
@ParameterizedTest @ValueSource(strings = {"0.1.7-rc.2", "0.2.0-rc.1", "0.2.1-rc.2"})
|
||||
void recordedModelReplyCorrelatesPreResponseReceiptAndIncludesCachedInput(String version) throws Exception {
|
||||
var mapper = new ObjectMapper();
|
||||
try (var input = getClass().getResourceAsStream("/dsh/" + version + "-sdk.jsonl")) {
|
||||
@ -19,13 +20,34 @@ class DshRecordedProtocolTest {
|
||||
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");
|
||||
var turn = new DshSdkTurn(root, "0.2.1-rc.2".equals(version) ? "probe" : "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("0.2.1-rc.2".equals(version) ? 5699 : 5421, turn.inputTokens());
|
||||
assertEquals(4, turn.outputTokens());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void candidateRecordedShellTaskKeepsToolReceiptsAndJobMetadataCorrelated() throws Exception {
|
||||
var mapper = new ObjectMapper();
|
||||
try (var input = getClass().getResourceAsStream("/dsh/0.2.1-rc.2-tool-sdk.jsonl")) {
|
||||
assertNotNull(input);
|
||||
var frames = new String(input.readAllBytes(), StandardCharsets.UTF_8).lines()
|
||||
.map(line -> { try { return mapper.readTree(line); } catch (Exception e) { throw new IllegalStateException(e); } }).toList();
|
||||
String root = frames.stream().filter(frame -> frame.path("params").has("sessionId")).findFirst().orElseThrow().path("params").path("sessionId").asText();
|
||||
var turn = new DshSdkTurn(root, "probe");
|
||||
var events = new java.util.ArrayList<JsonNode>();
|
||||
for (var frame : frames) events.addAll(turn.accept(frame));
|
||||
var calls = events.stream().filter(e -> "tool/call".equals(e.path("type").asText())).toList();
|
||||
var results = events.stream().filter(e -> "tool/result".equals(e.path("type").asText())).toList();
|
||||
assertEquals(1, calls.size()); assertEquals(1, results.size());
|
||||
assertEquals("bash", calls.getFirst().path("data").path("name").asText());
|
||||
assertEquals(calls.getFirst().path("data").path("callId").asText(), results.getFirst().path("data").path("message").path("toolCallId").asText());
|
||||
assertFalse(results.getFirst().path("data").path("message").path("isError").asBoolean());
|
||||
assertTrue(turn.complete()); assertEquals(RuntimeEventType.COMPLETED, turn.terminalType());
|
||||
assertEquals("DSH_TOOL_OK", turn.answer()); assertTrue(turn.inputTokens() > 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -58,4 +58,19 @@ class DshSdkTurnTest {
|
||||
{"method":"session.event","params":{"sessionId":"root","event":{"seq":2,"type":"unknown/required","data":{}}}}""")));
|
||||
}
|
||||
|
||||
|
||||
@Test void candidateMetadataEventsDoNotCompleteTheRootTurn() 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\"}]}}}}"));
|
||||
long seq = 2;
|
||||
for (String type : java.util.List.of("job/registered", "job/settled", "working-directory/change")) {
|
||||
var event = json.createObjectNode(); event.put("method", "session.event");
|
||||
var params = event.putObject("params"); params.put("sessionId", "root");
|
||||
var data = params.putObject("event"); data.put("seq", seq++); data.put("type", type); data.putObject("data");
|
||||
assertDoesNotThrow(() -> turn.accept(event));
|
||||
assertFalse(turn.complete());
|
||||
}
|
||||
assertEquals(0, turn.inputTokens());
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,9 @@ class DshPinnedInstallerTest {
|
||||
@Test
|
||||
void catalogRequiresExactKnownVersionAndCorrectHostPlatform() {
|
||||
assertEquals("0.2.0-rc.1", DshPinnedInstaller.release("0.2.0-rc.1").version());
|
||||
assertTrue(DshPinnedInstaller.catalog().stream().anyMatch(release -> release.version().equals("0.2.1-rc.2")));
|
||||
assertEquals("sha512-tvY+yhfKRifthbqPe/OK0AYxM5ZkvqfYq/jMYgJcEUxiRRL1Vwra3Ti5TB6AXgSWQdiPHYh/Vp6SJa9yHwMNuw==",
|
||||
DshPinnedInstaller.release("0.2.1-rc.2").integrity());
|
||||
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"));
|
||||
@ -24,11 +27,12 @@ class DshPinnedInstallerTest {
|
||||
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-"));
|
||||
}
|
||||
var entries = packages.fields();
|
||||
while (entries.hasNext()) {
|
||||
var entry = entries.next();
|
||||
if (entry.getKey().isEmpty()) continue; // local root manifest
|
||||
assertTrue(entry.getValue().path("resolved").asText().startsWith("https://registry.npmjs.org/"), entry.getKey());
|
||||
assertTrue(entry.getValue().path("integrity").asText().startsWith("sha512-"), entry.getKey());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
21
mateclaw-server/src/test/resources/dsh/0.2.1-rc.2-sdk.jsonl
Normal file
21
mateclaw-server/src/test/resources/dsh/0.2.1-rc.2-sdk.jsonl
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -42,3 +42,29 @@ it('lets an administrator repair malformed stored patches', async () => {
|
||||
expect(api.saveConfig).toHaveBeenCalledWith(expect.objectContaining({ 'dsh.patch_paths': '[]' }))
|
||||
expect(host.querySelector('.state-pill')?.textContent).toBe('已就绪')
|
||||
})
|
||||
|
||||
|
||||
it('renders only the server catalog and keeps an installed catalog version selected', async () => {
|
||||
api.status.mockReset()
|
||||
api.status.mockResolvedValue({ data: { state: 'READY', installed: true, config: {}, managed: {},
|
||||
activeVersion: '0.2.0-rc.1', catalog: [
|
||||
{ version: '0.2.1-rc.2', channel: 'next' }, { version: '0.2.0-rc.1', channel: 'retained' },
|
||||
] } })
|
||||
const host = document.createElement('div'); document.body.append(host)
|
||||
const app = createApp(DshSettings); app.mount(host); unmount = () => app.unmount()
|
||||
await flush()
|
||||
const select = host.querySelector('select')!
|
||||
expect([...select.options].map(item => item.value)).toEqual(['0.2.1-rc.2', '0.2.0-rc.1'])
|
||||
expect(select.value).toBe('0.2.0-rc.1')
|
||||
})
|
||||
|
||||
|
||||
it('blocks upgrades when no trusted catalog is available', async () => {
|
||||
api.status.mockReset()
|
||||
api.status.mockResolvedValue({ data: { state: 'READY', installed: true, config: {}, managed: {}, catalog: [] } })
|
||||
const host = document.createElement('div'); document.body.append(host)
|
||||
const app = createApp(DshSettings); app.mount(host); unmount = () => app.unmount()
|
||||
await flush()
|
||||
const install = [...host.querySelectorAll('button')].find(item => item.textContent === '安装 / 升级所选版本')!
|
||||
expect(install.disabled).toBe(true)
|
||||
})
|
||||
|
||||
@ -38,10 +38,10 @@
|
||||
|
||||
<div class="settings-card install-card">
|
||||
<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>
|
||||
<label>目标版本 <select v-model="targetVersion" :disabled="busy || upgrading"><option v-for="release in status.catalog || []" :key="release.version" :value="release.version">{{ release.version }} · {{ release.version.includes('-') ? '预发布' : '正式版' }}</option></select></label>
|
||||
<p class="muted-note">当前版本:{{ status.activeVersion || '未知 / 外部安装' }}。候选版本需完成本机检测,不代表已通过所有平台验证。</p>
|
||||
<div class="actions">
|
||||
<button class="btn-secondary" :disabled="busy || upgrading" @click="install">安装 / 升级所选版本</button>
|
||||
<button class="btn-secondary" :disabled="busy || upgrading || !targetVersion" @click="install">安装 / 升级所选版本</button>
|
||||
<button class="btn-secondary" :disabled="busy || upgrading || !status.installed" @click="testConnection">测试 SDK 握手</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>
|
||||
@ -80,7 +80,7 @@ const busy = ref(false)
|
||||
const error = ref('')
|
||||
const status = reactive<any>({ state: 'NOT_INSTALLED', installed: false, enabled: false, config: {}, artifactManifestConfigured: false })
|
||||
const form = reactive(createEmptyDshConfigForm())
|
||||
const targetVersion = ref('0.2.0-rc.1')
|
||||
const targetVersion = ref('')
|
||||
const operation = reactive<Record<string, string>>({})
|
||||
const upgrading = computed(() => !!operation.id && !['COMPLETED', 'FAILED', 'INTERRUPTED'].includes(operation.state || ''))
|
||||
let pollTimer: ReturnType<typeof setTimeout> | undefined
|
||||
@ -99,6 +99,10 @@ const steps = computed(() => [
|
||||
function applyResponse(response: any) {
|
||||
const data = response?.data ?? response
|
||||
Object.assign(status, data)
|
||||
const versions: string[] = (data?.catalog || []).map((release: { version: string }) => release.version)
|
||||
if (!versions.includes(targetVersion.value)) {
|
||||
targetVersion.value = versions.includes(data?.activeVersion) ? data.activeVersion : versions[0] || ''
|
||||
}
|
||||
if (data?.managed) Object.assign(form, managedConfigToForm(data.managed))
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user