mirror of
https://github.com/slhaf/Partner.git
synced 2026-05-12 08:43:02 +08:00
refactor(watch): support configurable directory watch depth
This commit is contained in:
@@ -11,11 +11,11 @@ import io.modelcontextprotocol.spec.McpSchema;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
import work.slhaf.partner.api.common.support.DirectoryWatchSupport;
|
||||
import work.slhaf.partner.common.mcp.InProcessMcpTransport;
|
||||
import work.slhaf.partner.core.action.entity.MetaActionInfo;
|
||||
import work.slhaf.partner.core.action.exception.ActionInitFailedException;
|
||||
import work.slhaf.partner.core.action.runner.execution.CommandExecutionService;
|
||||
import work.slhaf.partner.core.action.runner.support.DirectoryWatchSupport;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
@@ -58,7 +58,7 @@ public class DynamicActionMcpManager implements AutoCloseable {
|
||||
.capabilities(serverCapabilities)
|
||||
.jsonMapper(McpJsonMapper.getDefault())
|
||||
.build();
|
||||
this.watchSupport = new DirectoryWatchSupport(new DirectoryWatchSupport.Context(root), executor, true, this::loadExisting)
|
||||
this.watchSupport = new DirectoryWatchSupport(new DirectoryWatchSupport.Context(root), executor, 1, this::loadExisting)
|
||||
.onCreate(this::handleCreate)
|
||||
.onModify(this::handleModify)
|
||||
.onDelete(this::handleDelete)
|
||||
|
||||
@@ -6,11 +6,11 @@ import io.modelcontextprotocol.client.McpSyncClient;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import work.slhaf.partner.api.common.support.DirectoryWatchSupport;
|
||||
import work.slhaf.partner.core.action.entity.MetaActionInfo;
|
||||
import work.slhaf.partner.core.action.runner.LocalRunnerClient;
|
||||
import work.slhaf.partner.core.action.runner.policy.ExecutionPolicy;
|
||||
import work.slhaf.partner.core.action.runner.policy.RunnerExecutionPolicyListener;
|
||||
import work.slhaf.partner.core.action.runner.support.DirectoryWatchSupport;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
@@ -46,7 +46,7 @@ public class McpConfigWatcher implements AutoCloseable, RunnerExecutionPolicyLis
|
||||
this.mcpClientRegistry = mcpClientRegistry;
|
||||
this.mcpTransportFactory = mcpTransportFactory;
|
||||
this.mcpMetaRegistry = mcpMetaRegistry;
|
||||
this.watchSupport = new DirectoryWatchSupport(new DirectoryWatchSupport.Context(root), executor, false, this::loadInitial)
|
||||
this.watchSupport = new DirectoryWatchSupport(new DirectoryWatchSupport.Context(root), executor, 0, this::loadInitial)
|
||||
.onCreate(this::handleCreate)
|
||||
.onModify((thisDir, context) -> checkAndReload(true))
|
||||
.onDelete(this::handleDelete)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package work.slhaf.partner.core.action.runner.mcp;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import work.slhaf.partner.core.action.runner.support.DirectoryWatchSupport;
|
||||
import work.slhaf.partner.api.common.support.DirectoryWatchSupport;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
@@ -18,7 +18,7 @@ public class McpDescWatcher implements AutoCloseable {
|
||||
public McpDescWatcher(Path root, McpMetaRegistry mcpMetaRegistry, ExecutorService executor) throws IOException {
|
||||
this.root = root;
|
||||
this.mcpMetaRegistry = mcpMetaRegistry;
|
||||
this.watchSupport = new DirectoryWatchSupport(new DirectoryWatchSupport.Context(root), executor, true, () -> mcpMetaRegistry.loadDirectory(root))
|
||||
this.watchSupport = new DirectoryWatchSupport(new DirectoryWatchSupport.Context(root), executor, 0, () -> mcpMetaRegistry.loadDirectory(root))
|
||||
.onCreate(this::handleUpsert)
|
||||
.onModify(this::handleUpsert)
|
||||
.onDelete(this::handleDelete)
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
package work.slhaf.partner.core.action.runner.support;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static java.nio.file.StandardWatchEventKinds.*;
|
||||
|
||||
@Slf4j
|
||||
public class DirectoryWatchSupport implements Closeable {
|
||||
|
||||
private final Context ctx;
|
||||
private final Map<WatchEvent.Kind<?>, EventHandler> handlers = new HashMap<>();
|
||||
private final ExecutorService executor;
|
||||
private final boolean watchAll;
|
||||
private final InitLoader initLoader;
|
||||
|
||||
public DirectoryWatchSupport(Context ctx, ExecutorService executor, boolean watchAll, InitLoader initLoader) {
|
||||
this.ctx = ctx;
|
||||
this.executor = executor;
|
||||
this.watchAll = watchAll;
|
||||
this.initLoader = initLoader;
|
||||
}
|
||||
|
||||
public DirectoryWatchSupport onCreate(EventHandler handler) {
|
||||
ctx.kinds().add(ENTRY_CREATE);
|
||||
handlers.put(ENTRY_CREATE, handler);
|
||||
return this;
|
||||
}
|
||||
|
||||
public DirectoryWatchSupport onModify(EventHandler handler) {
|
||||
ctx.kinds().add(ENTRY_MODIFY);
|
||||
handlers.put(ENTRY_MODIFY, handler);
|
||||
return this;
|
||||
}
|
||||
|
||||
public DirectoryWatchSupport onDelete(EventHandler handler) {
|
||||
ctx.kinds().add(ENTRY_DELETE);
|
||||
handlers.put(ENTRY_DELETE, handler);
|
||||
return this;
|
||||
}
|
||||
|
||||
public DirectoryWatchSupport onOverflow(EventHandler handler) {
|
||||
ctx.kinds().add(OVERFLOW);
|
||||
handlers.put(OVERFLOW, handler);
|
||||
return this;
|
||||
}
|
||||
|
||||
public void start() {
|
||||
registerPath();
|
||||
if (initLoader != null) {
|
||||
initLoader.load();
|
||||
}
|
||||
executor.execute(buildWatchTask());
|
||||
}
|
||||
|
||||
public Context context() {
|
||||
return ctx;
|
||||
}
|
||||
|
||||
public boolean isWatching(Path dir) {
|
||||
return ctx.watchKeys().values().stream().anyMatch(dir::equals);
|
||||
}
|
||||
|
||||
public void registerDirectory(Path dir) throws IOException {
|
||||
if (!java.nio.file.Files.isDirectory(dir) || isWatching(dir)) {
|
||||
return;
|
||||
}
|
||||
WatchEvent.Kind<?>[] kindsArray = ctx.kinds().toArray(WatchEvent.Kind[]::new);
|
||||
WatchKey key = dir.register(ctx.watchService(), kindsArray);
|
||||
ctx.watchKeys().put(key, dir);
|
||||
}
|
||||
|
||||
private void registerPath() {
|
||||
try {
|
||||
registerDirectory(ctx.root());
|
||||
if (!watchAll) {
|
||||
return;
|
||||
}
|
||||
try (Stream<Path> walk = Files.list(ctx.root()).filter(Files::isDirectory)) {
|
||||
for (Path dir : walk.toList()) {
|
||||
registerDirectory(dir);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("监听目录注册失败: ", e);
|
||||
}
|
||||
}
|
||||
|
||||
private Runnable buildWatchTask() {
|
||||
return () -> {
|
||||
String rootStr = ctx.root().toString();
|
||||
log.info("行动程序目录监听器已启动,监听目录: {}", rootStr);
|
||||
while (true) {
|
||||
WatchKey key = null;
|
||||
try {
|
||||
key = ctx.watchService().take();
|
||||
List<WatchEvent<?>> events = key.pollEvents();
|
||||
for (WatchEvent<?> event : events) {
|
||||
WatchEvent.Kind<?> kind = event.kind();
|
||||
Object context = event.context();
|
||||
log.debug("文件目录监听事件: {} - {} - {}", rootStr, kind.name(), context);
|
||||
Path thisDir = (Path) key.watchable();
|
||||
EventHandler handler = handlers.get(kind);
|
||||
if (handler == null) {
|
||||
continue;
|
||||
}
|
||||
handler.handle(thisDir, context instanceof Path path ? thisDir.resolve(path) : null);
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
log.info("监听线程被中断,准备退出...");
|
||||
Thread.currentThread().interrupt();
|
||||
break;
|
||||
} catch (ClosedWatchServiceException e) {
|
||||
log.info("WatchService 已关闭,监听线程退出。");
|
||||
break;
|
||||
} finally {
|
||||
if (key != null) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
ctx.watchService().close();
|
||||
ctx.watchKeys().clear();
|
||||
}
|
||||
|
||||
public interface EventHandler {
|
||||
void handle(Path thisDir, Path context);
|
||||
}
|
||||
|
||||
public interface InitLoader {
|
||||
void load();
|
||||
}
|
||||
|
||||
public record Context(Path root, WatchService watchService, Map<WatchKey, Path> watchKeys,
|
||||
Set<WatchEvent.Kind<?>> kinds) {
|
||||
public Context(Path root) throws IOException {
|
||||
this(root, FileSystems.getDefault().newWatchService(), new HashMap<>(), new LinkedHashSet<>());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -328,6 +328,7 @@ public class LocalRunnerClientTest {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void testDynamicWatchDeleteBehavior(@TempDir Path tempDir) throws IOException, InterruptedException {
|
||||
ConcurrentHashMap<String, MetaActionInfo> existedMetaActions = new ConcurrentHashMap<>();
|
||||
@@ -521,6 +522,7 @@ public class LocalRunnerClientTest {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void testDescMcpIgnoreInvalidFileName(@TempDir Path tempDir) throws IOException, InterruptedException {
|
||||
ConcurrentHashMap<String, MetaActionInfo> existedMetaActions = new ConcurrentHashMap<>();
|
||||
@@ -809,6 +811,7 @@ public class LocalRunnerClientTest {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Nested
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package work.slhaf.partner.core.action.runner;
|
||||
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import work.slhaf.partner.core.action.entity.MetaActionInfo;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
import static work.slhaf.partner.core.action.runner.LocalRunnerClientTest.Await.waitForCondition;
|
||||
import static work.slhaf.partner.core.action.runner.LocalRunnerClientTest.Common.*;
|
||||
import static work.slhaf.partner.core.action.runner.LocalRunnerClientTest.Fs.*;
|
||||
|
||||
class LocalRunnerClientWatchDepthTest {
|
||||
|
||||
@Test
|
||||
void testDynamicWatchIgnoresGrandchildDirectories(@TempDir Path tempDir) throws IOException, InterruptedException {
|
||||
ConcurrentHashMap<String, MetaActionInfo> existedMetaActions = new ConcurrentHashMap<>();
|
||||
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
new LocalRunnerClient(existedMetaActions, executor, tempDir.toString());
|
||||
|
||||
try {
|
||||
Path nestedActionDir = tempDir.resolve("action").resolve("dynamic").resolve("group").resolve("demo_action_nested");
|
||||
Files.createDirectories(nestedActionDir);
|
||||
|
||||
writeRunFile(nestedActionDir);
|
||||
writeDescJson(nestedActionDir, "nested action");
|
||||
waitForCondition(() -> existedMetaActions.containsKey("local::demo_action_nested"), 1000);
|
||||
|
||||
Assertions.assertFalse(existedMetaActions.containsKey("local::demo_action_nested"));
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDescMcpIgnoresNestedDirectories(@TempDir Path tempDir) throws IOException, InterruptedException {
|
||||
ConcurrentHashMap<String, MetaActionInfo> existedMetaActions = new ConcurrentHashMap<>();
|
||||
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
String actionKey = "local::desc_nested";
|
||||
existedMetaActions.put(actionKey, buildMetaActionInfo("base"));
|
||||
new LocalRunnerClient(existedMetaActions, executor, tempDir.toString());
|
||||
|
||||
try {
|
||||
Path nestedDescDir = tempDir.resolve("action").resolve("mcp").resolve("desc").resolve("nested");
|
||||
Files.createDirectories(nestedDescDir);
|
||||
|
||||
writeDescMcpJson(nestedDescDir, actionKey, "nested override");
|
||||
waitForCondition(() -> {
|
||||
MetaActionInfo current = getMetaActionInfo(existedMetaActions, actionKey);
|
||||
return current != null && "nested override".equals(current.getDescription());
|
||||
}, 1000);
|
||||
|
||||
MetaActionInfo info = getMetaActionInfo(existedMetaActions, actionKey);
|
||||
Assertions.assertNotNull(info);
|
||||
Assertions.assertEquals("base", info.getDescription());
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCommonMcpIgnoresNestedDirectories(@TempDir Path tempDir) throws IOException, InterruptedException {
|
||||
ConcurrentHashMap<String, MetaActionInfo> existedMetaActions = new ConcurrentHashMap<>();
|
||||
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
new LocalRunnerClient(existedMetaActions, executor, tempDir.toString());
|
||||
|
||||
try {
|
||||
Path nestedDir = tempDir.resolve("action").resolve("mcp").resolve("nested");
|
||||
Files.createDirectories(nestedDir);
|
||||
Path configFile = nestedDir.resolve("servers.json");
|
||||
|
||||
String config = buildCommonMcpConfig(
|
||||
buildStdioServerEntry("mcp-deepwiki", "mcp-deepwiki@latest")
|
||||
);
|
||||
writeCommonMcpConfig(configFile, config);
|
||||
waitForCondition(() -> hasActionKey(existedMetaActions, key -> key.startsWith("mcp-deepwiki::")), 2000);
|
||||
|
||||
Assertions.assertFalse(hasActionKey(existedMetaActions, key -> key.startsWith("mcp-deepwiki::")));
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user