From c5f6c4e0aecbd81709b715c36e2d131e9443d353 Mon Sep 17 00:00:00 2001 From: slhafzjw Date: Wed, 14 Jan 2026 19:57:24 +0800 Subject: [PATCH] fix(LocalRunnerClient): recover desc watcher after root deletion and expand DescMcp tests --- .../core/action/runner/LocalRunnerClient.java | 11 + .../action/runner/LocalRunnerClientTest.java | 308 ++++++++++++++++++ 2 files changed, 319 insertions(+) diff --git a/Partner-Main/src/main/java/work/slhaf/partner/core/action/runner/LocalRunnerClient.java b/Partner-Main/src/main/java/work/slhaf/partner/core/action/runner/LocalRunnerClient.java index 8a26f163..ab9eead0 100644 --- a/Partner-Main/src/main/java/work/slhaf/partner/core/action/runner/LocalRunnerClient.java +++ b/Partner-Main/src/main/java/work/slhaf/partner/core/action/runner/LocalRunnerClient.java @@ -517,6 +517,17 @@ public class LocalRunnerClient extends RunnerClient { boolean valid = key.reset(); if (!valid) { log.info("WatchKey 已失效,停止监听该目录: {}", key.watchable()); + ctx.watchKeys.remove(key); + if (key.watchable().equals(ctx.root)) { + try { + Files.createDirectories(ctx.root); + registerPath(); + if (initLoader != null) + initLoader.load(); + } catch (IOException e) { + log.error("重建根目录并重新注册监听失败: {}", ctx.root, e); + } + } } } } diff --git a/Partner-Main/src/test/java/work/slhaf/partner/core/action/runner/LocalRunnerClientTest.java b/Partner-Main/src/test/java/work/slhaf/partner/core/action/runner/LocalRunnerClientTest.java index 0353f375..7f12551e 100644 --- a/Partner-Main/src/test/java/work/slhaf/partner/core/action/runner/LocalRunnerClientTest.java +++ b/Partner-Main/src/test/java/work/slhaf/partner/core/action/runner/LocalRunnerClientTest.java @@ -11,7 +11,10 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.util.ArrayList; import java.util.Comparator; +import java.util.HashMap; +import java.util.List; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -55,6 +58,28 @@ public class LocalRunnerClientTest { Files.writeString(descPath, json); } + static void writeDescMcpJson(Path descDir, String actionKey, String description) throws IOException { + Path descPath = descDir.resolve(actionKey + ".desc.json"); + log.debug("写入路径: {}", descPath); + String json = "{\n" + + " \"io\": true,\n" + + " \"params\": {},\n" + + " \"description\": \"" + description + "\",\n" + + " \"tags\": [\"tag\"],\n" + + " \"preActions\": [\"pre\"],\n" + + " \"postActions\": [\"post\"],\n" + + " \"strictDependencies\": true,\n" + + " \"responseSchema\": {}\n" + + "}\n"; + Files.writeString(descPath, json); + } + + static void writeInvalidDescMcpJson(Path descDir, String actionKey) throws IOException { + Path descPath = descDir.resolve(actionKey + ".desc.json"); + log.debug("写入路径: {}", descPath); + Files.writeString(descPath, "{ invalid json"); + } + @SuppressWarnings("SameParameterValue") static void writeDescJsonAtomic(Path actionDir, String description) throws IOException { Path descPath = actionDir.resolve("desc.json"); @@ -117,6 +142,18 @@ public class LocalRunnerClientTest { return existedMetaActions.keySet().stream().anyMatch(predicate); } + static MetaActionInfo buildMetaActionInfo(String description) { + MetaActionInfo info = new MetaActionInfo(); + info.setIo(true); + info.setParams(new HashMap<>()); + info.setDescription(description); + info.setTags(new ArrayList<>(List.of("tag"))); + info.setPreActions(new ArrayList<>(List.of("pre"))); + info.setPostActions(new ArrayList<>(List.of("post"))); + info.setStrictDependencies(true); + return info; + } + static String buildCommonMcpConfig(String... serverEntries) { StringBuilder builder = new StringBuilder(); builder.append("{\n"); @@ -332,6 +369,277 @@ public class LocalRunnerClientTest { } } + @Nested + class DescMcpTest { + + @Test + void testDescMcpWatchCreateModifyDelete(@TempDir Path tempDir) throws IOException, InterruptedException { + ConcurrentHashMap existedMetaActions = new ConcurrentHashMap<>(); + ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); + String actionKey = "local::desc_action"; + existedMetaActions.put(actionKey, buildMetaActionInfo("base")); + new LocalRunnerClient(existedMetaActions, executor, tempDir.toString()); + + try { + Path descDir = tempDir.resolve("action").resolve("mcp").resolve("desc"); + Files.createDirectories(descDir); + + writeDescMcpJson(descDir, actionKey, "v1"); + waitForCondition(() -> { + MetaActionInfo info = getMetaActionInfo(existedMetaActions, actionKey); + return info != null && "v1".equals(info.getDescription()); + }, 2000); + MetaActionInfo info = getMetaActionInfo(existedMetaActions, actionKey); + Assertions.assertNotNull(info); + Assertions.assertEquals("v1", info.getDescription()); + Assertions.assertTrue(info.isIo()); + Assertions.assertTrue(info.isStrictDependencies()); + Assertions.assertFalse(info.getTags().isEmpty()); + + writeDescMcpJson(descDir, actionKey, "v2"); + waitForCondition(() -> { + MetaActionInfo current = getMetaActionInfo(existedMetaActions, actionKey); + return current != null && "v2".equals(current.getDescription()); + }, 2000); + info = getMetaActionInfo(existedMetaActions, actionKey); + Assertions.assertNotNull(info); + Assertions.assertEquals("v2", info.getDescription()); + + Files.deleteIfExists(descDir.resolve(actionKey + ".desc.json")); + waitForCondition(() -> { + MetaActionInfo current = getMetaActionInfo(existedMetaActions, actionKey); + return current != null + && !current.isIo() + && !current.isStrictDependencies() + && current.getTags().isEmpty() + && current.getPreActions().isEmpty() + && current.getPostActions().isEmpty(); + }, 2000); + info = getMetaActionInfo(existedMetaActions, actionKey); + Assertions.assertNotNull(info); + Assertions.assertFalse(info.isIo()); + Assertions.assertFalse(info.isStrictDependencies()); + Assertions.assertTrue(info.getTags().isEmpty()); + Assertions.assertTrue(info.getPreActions().isEmpty()); + Assertions.assertTrue(info.getPostActions().isEmpty()); + } finally { + executor.shutdownNow(); + } + } + + @Test + void testDescMcpInvalidJsonRecovery(@TempDir Path tempDir) throws IOException, InterruptedException { + ConcurrentHashMap existedMetaActions = new ConcurrentHashMap<>(); + ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); + String actionKey = "local::desc_invalid"; + existedMetaActions.put(actionKey, buildMetaActionInfo("base")); + new LocalRunnerClient(existedMetaActions, executor, tempDir.toString()); + + try { + Path descDir = tempDir.resolve("action").resolve("mcp").resolve("desc"); + Files.createDirectories(descDir); + + writeInvalidDescMcpJson(descDir, actionKey); + waitForCondition(() -> { + MetaActionInfo info = getMetaActionInfo(existedMetaActions, actionKey); + return info != null + && !info.isIo() + && !info.isStrictDependencies() + && info.getTags().isEmpty() + && info.getPreActions().isEmpty() + && info.getPostActions().isEmpty(); + }, 2000); + MetaActionInfo info = getMetaActionInfo(existedMetaActions, actionKey); + Assertions.assertNotNull(info); + Assertions.assertFalse(info.isIo()); + Assertions.assertFalse(info.isStrictDependencies()); + Assertions.assertTrue(info.getTags().isEmpty()); + Assertions.assertTrue(info.getPreActions().isEmpty()); + Assertions.assertTrue(info.getPostActions().isEmpty()); + + writeDescMcpJson(descDir, actionKey, "fixed"); + waitForCondition(() -> { + MetaActionInfo current = getMetaActionInfo(existedMetaActions, actionKey); + return current != null && "fixed".equals(current.getDescription()); + }, 2000); + info = getMetaActionInfo(existedMetaActions, actionKey); + Assertions.assertNotNull(info); + Assertions.assertEquals("fixed", info.getDescription()); + Assertions.assertTrue(info.isIo()); + Assertions.assertTrue(info.isStrictDependencies()); + } finally { + executor.shutdownNow(); + } + } + + @Test + void testDescMcpIgnoreInvalidFileName(@TempDir Path tempDir) throws IOException, InterruptedException { + ConcurrentHashMap existedMetaActions = new ConcurrentHashMap<>(); + ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); + String actionKey = "local::desc_ignore"; + existedMetaActions.put(actionKey, buildMetaActionInfo("base")); + new LocalRunnerClient(existedMetaActions, executor, tempDir.toString()); + + try { + Path descDir = tempDir.resolve("action").resolve("mcp").resolve("desc"); + Files.createDirectories(descDir); + + Files.writeString(descDir.resolve("local-desc.desc.json"), "{ \"description\": \"bad\" }"); + Files.writeString(descDir.resolve(actionKey + ".json"), "{ \"description\": \"bad\" }"); + waitForCondition(() -> existedMetaActions.size() > 1, 500); + + MetaActionInfo info = getMetaActionInfo(existedMetaActions, actionKey); + Assertions.assertNotNull(info); + Assertions.assertEquals("base", info.getDescription()); + Assertions.assertTrue(info.isIo()); + Assertions.assertEquals(1, existedMetaActions.size()); + } finally { + executor.shutdownNow(); + } + } + + @Test + void testDescMcpNoActionKeyPresent(@TempDir Path tempDir) throws IOException, InterruptedException { + ConcurrentHashMap existedMetaActions = new ConcurrentHashMap<>(); + ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); + new LocalRunnerClient(existedMetaActions, executor, tempDir.toString()); + + try { + Path descDir = tempDir.resolve("action").resolve("mcp").resolve("desc"); + Files.createDirectories(descDir); + + String actionKey = "local::missing_action"; + writeDescMcpJson(descDir, actionKey, "desc"); + waitForCondition(() -> existedMetaActions.containsKey(actionKey), 500); + Assertions.assertFalse(existedMetaActions.containsKey(actionKey)); + } finally { + executor.shutdownNow(); + } + } + + @Test + void testDescMcpRapidCreateDelete(@TempDir Path tempDir) throws IOException, InterruptedException { + ConcurrentHashMap existedMetaActions = new ConcurrentHashMap<>(); + ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); + String actionKey = "local::desc_rapid"; + existedMetaActions.put(actionKey, buildMetaActionInfo("base")); + new LocalRunnerClient(existedMetaActions, executor, tempDir.toString()); + + try { + Path descDir = tempDir.resolve("action").resolve("mcp").resolve("desc"); + Files.createDirectories(descDir); + + writeDescMcpJson(descDir, actionKey, "v1"); + Files.deleteIfExists(descDir.resolve(actionKey + ".desc.json")); + + waitForCondition(() -> { + MetaActionInfo info = getMetaActionInfo(existedMetaActions, actionKey); + return info != null + && !info.isIo() + && !info.isStrictDependencies() + && info.getTags().isEmpty() + && info.getPreActions().isEmpty() + && info.getPostActions().isEmpty(); + }, 2000); + MetaActionInfo info = getMetaActionInfo(existedMetaActions, actionKey); + Assertions.assertNotNull(info); + Assertions.assertFalse(info.isIo()); + Assertions.assertFalse(info.isStrictDependencies()); + Assertions.assertTrue(info.getTags().isEmpty()); + Assertions.assertTrue(info.getPreActions().isEmpty()); + Assertions.assertTrue(info.getPostActions().isEmpty()); + } finally { + executor.shutdownNow(); + } + } + + @Test + void testDescMcpRapidDeleteCreate(@TempDir Path tempDir) throws IOException, InterruptedException { + ConcurrentHashMap existedMetaActions = new ConcurrentHashMap<>(); + ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); + String actionKey = "local::desc_rapid_restore"; + existedMetaActions.put(actionKey, buildMetaActionInfo("base")); + new LocalRunnerClient(existedMetaActions, executor, tempDir.toString()); + + try { + Path descDir = tempDir.resolve("action").resolve("mcp").resolve("desc"); + Files.createDirectories(descDir); + + writeDescMcpJson(descDir, actionKey, "v1"); + waitForCondition(() -> { + MetaActionInfo info = getMetaActionInfo(existedMetaActions, actionKey); + return info != null && "v1".equals(info.getDescription()); + }, 2000); + + Files.deleteIfExists(descDir.resolve(actionKey + ".desc.json")); + waitForCondition(() -> { + MetaActionInfo info = getMetaActionInfo(existedMetaActions, actionKey); + return info != null + && !info.isIo() + && !info.isStrictDependencies() + && info.getTags().isEmpty() + && info.getPreActions().isEmpty() + && info.getPostActions().isEmpty(); + }, 2000); + + writeDescMcpJson(descDir, actionKey, "v2"); + waitForCondition(() -> { + MetaActionInfo info = getMetaActionInfo(existedMetaActions, actionKey); + return info != null && "v2".equals(info.getDescription()); + }, 2000); + MetaActionInfo info = getMetaActionInfo(existedMetaActions, actionKey); + Assertions.assertNotNull(info); + Assertions.assertEquals("v2", info.getDescription()); + } finally { + executor.shutdownNow(); + } + } + + @Test + void testDescMcpDirDeleteRecreate(@TempDir Path tempDir) throws IOException, InterruptedException { + ConcurrentHashMap existedMetaActions = new ConcurrentHashMap<>(); + ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); + String actionKey = "local::desc_dir_restore"; + existedMetaActions.put(actionKey, buildMetaActionInfo("base")); + new LocalRunnerClient(existedMetaActions, executor, tempDir.toString()); + + try { + Path descDir = tempDir.resolve("action").resolve("mcp").resolve("desc"); + Files.createDirectories(descDir); + + writeDescMcpJson(descDir, actionKey, "v1"); + waitForCondition(() -> { + MetaActionInfo info = getMetaActionInfo(existedMetaActions, actionKey); + return info != null && "v1".equals(info.getDescription()); + }, 2000); + + Files.deleteIfExists(descDir.resolve(actionKey + ".desc.json")); + deleteDirectory(descDir); + waitForCondition(() -> { + MetaActionInfo info = getMetaActionInfo(existedMetaActions, actionKey); + return info != null + && !info.isIo() + && !info.isStrictDependencies() + && info.getTags().isEmpty() + && info.getPreActions().isEmpty() + && info.getPostActions().isEmpty(); + }, 2000); + + Files.createDirectories(descDir); + writeDescMcpJson(descDir, actionKey, "v2"); + waitForCondition(() -> { + MetaActionInfo info = getMetaActionInfo(existedMetaActions, actionKey); + return info != null && "v2".equals(info.getDescription()); + }, 2000); + MetaActionInfo info = getMetaActionInfo(existedMetaActions, actionKey); + Assertions.assertNotNull(info); + Assertions.assertEquals("v2", info.getDescription()); + } finally { + executor.shutdownNow(); + } + } + } + @Nested class CommonMcpTest {