mirror of
https://github.com/slhaf/Partner.git
synced 2026-05-12 08:43:02 +08:00
feat(runner): add and register DynamicAction related builtin MetaAction provider
This commit is contained in:
@@ -31,6 +31,7 @@ import java.util.stream.Collectors;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
public class ActionCore extends PartnerCore<ActionCore> {
|
public class ActionCore extends PartnerCore<ActionCore> {
|
||||||
public static final String BUILTIN_LOCATION = "builtin";
|
public static final String BUILTIN_LOCATION = "builtin";
|
||||||
|
public static final String ORIGIN_LOCATION = "origin";
|
||||||
|
|
||||||
private final Lock cacheLock = new ReentrantLock();
|
private final Lock cacheLock = new ReentrantLock();
|
||||||
// 由于当前的执行器逻辑实现,平台线程池大小不得小于 2,这里规定为最小为 4
|
// 由于当前的执行器逻辑实现,平台线程池大小不得小于 2,这里规定为最小为 4
|
||||||
@@ -322,11 +323,15 @@ public class ActionCore extends PartnerCore<ActionCore> {
|
|||||||
throw new MetaActionNotFoundException("未找到对应的行动程序信息" + actionKey);
|
throw new MetaActionNotFoundException("未找到对应的行动程序信息" + actionKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
String[] split = actionKey.split("::");
|
String[] split = actionKey.split("::", 2);
|
||||||
if (split.length < 2) {
|
if (split.length < 2) {
|
||||||
throw new MetaActionNotFoundException("未找到对应的行动程序,原因: 传入的 actionKey(" + actionKey + ") 存在异常");
|
throw new MetaActionNotFoundException("未找到对应的行动程序,原因: 传入的 actionKey(" + actionKey + ") 存在异常");
|
||||||
}
|
}
|
||||||
MetaAction.Type type = BUILTIN_LOCATION.equals(split[0]) ? MetaAction.Type.BUILTIN : MetaAction.Type.MCP;
|
MetaAction.Type type = switch (split[0]) {
|
||||||
|
case BUILTIN_LOCATION -> MetaAction.Type.BUILTIN;
|
||||||
|
case ORIGIN_LOCATION -> MetaAction.Type.ORIGIN;
|
||||||
|
default -> MetaAction.Type.MCP;
|
||||||
|
};
|
||||||
return new MetaAction(
|
return new MetaAction(
|
||||||
split[1],
|
split[1],
|
||||||
metaActionInfo.getIo(),
|
metaActionInfo.getIo(),
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import java.util.ArrayList;
|
|||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
import static work.slhaf.partner.core.action.ActionCore.ORIGIN_LOCATION;
|
||||||
|
|
||||||
public class OriginExecutionService {
|
public class OriginExecutionService {
|
||||||
|
|
||||||
public OriginExecutionService() {
|
public OriginExecutionService() {
|
||||||
@@ -17,7 +19,7 @@ public class OriginExecutionService {
|
|||||||
|
|
||||||
public RunnerClient.RunnerResponse run(MetaAction metaAction) {
|
public RunnerClient.RunnerResponse run(MetaAction metaAction) {
|
||||||
RunnerClient.RunnerResponse response = new RunnerClient.RunnerResponse();
|
RunnerClient.RunnerResponse response = new RunnerClient.RunnerResponse();
|
||||||
File file = new File(metaAction.getLocation());
|
File file = new File(resolveOriginPath(metaAction));
|
||||||
String[] commands = CommandExecutionService.INSTANCE.buildFileExecutionCommands(metaAction.getLauncher(), metaAction.getParams(), file.getAbsolutePath());
|
String[] commands = CommandExecutionService.INSTANCE.buildFileExecutionCommands(metaAction.getLauncher(), metaAction.getParams(), file.getAbsolutePath());
|
||||||
WrappedLaunchSpec wrapped = ExecutionPolicyRegistry.INSTANCE.prepare(Arrays.stream(commands).toList());
|
WrappedLaunchSpec wrapped = ExecutionPolicyRegistry.INSTANCE.prepare(Arrays.stream(commands).toList());
|
||||||
List<String> wrappedCommands = new ArrayList<>();
|
List<String> wrappedCommands = new ArrayList<>();
|
||||||
@@ -28,4 +30,11 @@ public class OriginExecutionService {
|
|||||||
response.setData(execResult.getTotal());
|
response.setData(execResult.getTotal());
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String resolveOriginPath(MetaAction metaAction) {
|
||||||
|
if (ORIGIN_LOCATION.equals(metaAction.getLocation())) {
|
||||||
|
return metaAction.getName();
|
||||||
|
}
|
||||||
|
return metaAction.getLocation();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,11 +38,13 @@ public class BuiltinActionRegistry extends AbstractAgentModule.Standalone {
|
|||||||
List<BuiltinActionDefinition> builtinActionDefinitions = new ArrayList<>();
|
List<BuiltinActionDefinition> builtinActionDefinitions = new ArrayList<>();
|
||||||
BuiltinActionProvider commandActionProvider = new BuiltinCommandActionProvider();
|
BuiltinActionProvider commandActionProvider = new BuiltinCommandActionProvider();
|
||||||
BuiltinActionProvider capabilityActionProvider = new BuiltinCapabilityActionProvider();
|
BuiltinActionProvider capabilityActionProvider = new BuiltinCapabilityActionProvider();
|
||||||
BuiltinInterventionActionProvider interventionActionProvider = new BuiltinInterventionActionProvider();
|
BuiltinActionProvider interventionActionProvider = new BuiltinInterventionActionProvider();
|
||||||
|
BuiltinActionProvider dynamicActionProvider = new BuiltinDynamicActionProvider();
|
||||||
|
|
||||||
builtinActionDefinitions.addAll(commandActionProvider.provideBuiltinActions());
|
builtinActionDefinitions.addAll(commandActionProvider.provideBuiltinActions());
|
||||||
builtinActionDefinitions.addAll(capabilityActionProvider.provideBuiltinActions());
|
builtinActionDefinitions.addAll(capabilityActionProvider.provideBuiltinActions());
|
||||||
builtinActionDefinitions.addAll(interventionActionProvider.provideBuiltinActions());
|
builtinActionDefinitions.addAll(interventionActionProvider.provideBuiltinActions());
|
||||||
|
builtinActionDefinitions.addAll(dynamicActionProvider.provideBuiltinActions());
|
||||||
|
|
||||||
return builtinActionDefinitions;
|
return builtinActionDefinitions;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
package work.slhaf.partner.module.modules.action.builtin;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSON;
|
||||||
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
|
import kotlin.Unit;
|
||||||
|
import work.slhaf.partner.api.agent.factory.capability.annotation.InjectCapability;
|
||||||
|
import work.slhaf.partner.api.agent.factory.component.annotation.AgentComponent;
|
||||||
|
import work.slhaf.partner.api.agent.factory.component.annotation.InjectModule;
|
||||||
|
import work.slhaf.partner.core.action.ActionCapability;
|
||||||
|
import work.slhaf.partner.core.action.entity.*;
|
||||||
|
import work.slhaf.partner.module.modules.action.scheduler.ActionScheduler;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
import static work.slhaf.partner.core.action.ActionCore.BUILTIN_LOCATION;
|
||||||
|
|
||||||
|
@AgentComponent
|
||||||
|
class BuiltinDynamicActionProvider implements BuiltinActionProvider {
|
||||||
|
private static final String ORIGIN_LOCATION = "origin";
|
||||||
|
private static final long TEMP_ACTION_TTL_MILLIS = 30 * 60 * 1000L;
|
||||||
|
private static final String DYNAMIC_LOCATION = BUILTIN_LOCATION + "::" + "dynamic";
|
||||||
|
private final Set<String> basicTags = Set.of("Builtin MetaAction", "Dynamic Generation");
|
||||||
|
private final ConcurrentHashMap<String, TempDynamicActionRecord> tempDynamicActions = new ConcurrentHashMap<>();
|
||||||
|
@InjectCapability
|
||||||
|
private ActionCapability actionCapability;
|
||||||
|
@InjectModule
|
||||||
|
private ActionScheduler actionScheduler;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<BuiltinActionRegistry.BuiltinActionDefinition> provideBuiltinActions() {
|
||||||
|
return List.of(
|
||||||
|
buildGenerateDynamicActionDefinition(),
|
||||||
|
buildPersistDynamicActionDefinition()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private BuiltinActionRegistry.BuiltinActionDefinition buildGenerateDynamicActionDefinition() {
|
||||||
|
MetaActionInfo info = new MetaActionInfo(
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
Map.of(
|
||||||
|
"desc", "Human-readable description for the temporary action. Required because the generated action name is only a short id.",
|
||||||
|
"code", "Dynamic action source code content.",
|
||||||
|
"codeType", "Code extension, for example py/sh/js.",
|
||||||
|
"launcher", "Interpreter or launcher used for ORIGIN execution.",
|
||||||
|
"meta", "MetaActionInfo extra fields as JSON string. Available fields example: {\"io\":true,\"params\":{\"input\":\"user input\"},\"tags\":[\"dynamic\"],\"preActions\":[\"builtin::command::execute\"],\"postActions\":[\"builtin::dynamic::persist\"],\"strictDependencies\":false,\"responseSchema\":{\"result\":\"dynamic result\"}}"
|
||||||
|
),
|
||||||
|
"Generate a temporary ORIGIN action from source code and return a temporary actionKey.",
|
||||||
|
basicTags,
|
||||||
|
Set.of(),
|
||||||
|
Set.of(createActionKey("persist")),
|
||||||
|
false,
|
||||||
|
JSONObject.of(
|
||||||
|
"ok", "Whether the dynamic action was generated successfully.",
|
||||||
|
"actionKey", "Temporary ORIGIN actionKey."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return new BuiltinActionRegistry.BuiltinActionDefinition(createActionKey("generate"), info, params -> {
|
||||||
|
String desc = BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, "desc").trim();
|
||||||
|
if (desc.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("参数 desc 不能为空");
|
||||||
|
}
|
||||||
|
String code = BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, "code");
|
||||||
|
String codeType = BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, "codeType");
|
||||||
|
String launcher = BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, "launcher");
|
||||||
|
String metaJson = BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, "meta");
|
||||||
|
|
||||||
|
JSONObject meta = parseMeta(metaJson);
|
||||||
|
MetaActionInfo metaActionInfo = buildMetaActionInfo(meta, launcher, desc);
|
||||||
|
|
||||||
|
String tempName = "dyn-" + shortUuid();
|
||||||
|
String location = actionCapability.runnerClient().buildTmpPath(tempName, codeType);
|
||||||
|
MetaAction tempAction = new MetaAction(
|
||||||
|
tempName,
|
||||||
|
metaActionInfo.getIo(),
|
||||||
|
launcher,
|
||||||
|
MetaAction.Type.ORIGIN,
|
||||||
|
location
|
||||||
|
);
|
||||||
|
String actionKey = ORIGIN_LOCATION + "::" + location;
|
||||||
|
try {
|
||||||
|
actionCapability.runnerClient().tmpSerialize(tempAction, code, codeType);
|
||||||
|
} catch (java.io.IOException e) {
|
||||||
|
throw new IllegalStateException("临时动态行动序列化失败", e);
|
||||||
|
}
|
||||||
|
actionCapability.registerMetaActions(Map.of(actionKey, metaActionInfo));
|
||||||
|
|
||||||
|
ActionFileMetaData fileMetaData = buildActionFileMetaData(location, code, codeType);
|
||||||
|
StateAction cleanupAction = buildCleanupAction(actionKey);
|
||||||
|
tempDynamicActions.put(actionKey, new TempDynamicActionRecord(
|
||||||
|
actionKey,
|
||||||
|
location,
|
||||||
|
cleanupAction.getUuid(),
|
||||||
|
fileMetaData,
|
||||||
|
metaActionInfo
|
||||||
|
));
|
||||||
|
actionScheduler.schedule(cleanupAction);
|
||||||
|
return JSONObject.of(
|
||||||
|
"ok", true,
|
||||||
|
"actionKey", actionKey
|
||||||
|
).toJSONString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private BuiltinActionRegistry.BuiltinActionDefinition buildPersistDynamicActionDefinition() {
|
||||||
|
MetaActionInfo info = new MetaActionInfo(
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
Map.of("actionKey", "Temporary ORIGIN actionKey returned by generate."),
|
||||||
|
"Persist a temporary ORIGIN action and cancel its cleanup task.",
|
||||||
|
basicTags,
|
||||||
|
Set.of(createActionKey("generate")),
|
||||||
|
Set.of(),
|
||||||
|
false,
|
||||||
|
JSONObject.of(
|
||||||
|
"ok", "Whether the dynamic action was persisted successfully.",
|
||||||
|
"actionKey", "Temporary ORIGIN actionKey."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return new BuiltinActionRegistry.BuiltinActionDefinition(createActionKey("persist"), info, params -> {
|
||||||
|
String actionKey = BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, "actionKey");
|
||||||
|
TempDynamicActionRecord record = tempDynamicActions.get(actionKey);
|
||||||
|
if (record == null) {
|
||||||
|
throw new IllegalArgumentException("未找到对应临时动态行动: " + actionKey);
|
||||||
|
}
|
||||||
|
actionCapability.runnerClient().persistSerialize(record.metaActionInfo(), record.fileMetaData());
|
||||||
|
actionScheduler.cancel(record.cleanupActionId());
|
||||||
|
removeTempDynamicAction(actionKey);
|
||||||
|
return JSONObject.of(
|
||||||
|
"ok", true,
|
||||||
|
"actionKey", actionKey
|
||||||
|
).toJSONString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String createActionKey(String actionName) {
|
||||||
|
return DYNAMIC_LOCATION + "::" + actionName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JSONObject parseMeta(String metaJson) {
|
||||||
|
try {
|
||||||
|
return JSON.parseObject(metaJson);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalArgumentException("参数 meta 必须为合法 JSON 字符串", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private MetaActionInfo buildMetaActionInfo(JSONObject meta, String launcher, String description) {
|
||||||
|
return new MetaActionInfo(
|
||||||
|
Boolean.TRUE.equals(meta.getBoolean("io")),
|
||||||
|
launcher,
|
||||||
|
copyStringMap(meta.getJSONObject("params")),
|
||||||
|
description,
|
||||||
|
toOrderedSet(meta.getJSONArray("tags")),
|
||||||
|
toOrderedSet(meta.getJSONArray("preActions")),
|
||||||
|
toOrderedSet(meta.getJSONArray("postActions")),
|
||||||
|
Boolean.TRUE.equals(meta.getBoolean("strictDependencies")),
|
||||||
|
copyJsonObject(meta.getJSONObject("responseSchema"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ActionFileMetaData buildActionFileMetaData(String location, String code, String codeType) {
|
||||||
|
ActionFileMetaData fileMetaData = new ActionFileMetaData();
|
||||||
|
fileMetaData.setContent(code);
|
||||||
|
fileMetaData.setExt(normalizeCodeType(codeType));
|
||||||
|
fileMetaData.setName(extractFileBaseName(location, fileMetaData.getExt()));
|
||||||
|
return fileMetaData;
|
||||||
|
}
|
||||||
|
|
||||||
|
private StateAction buildCleanupAction(String actionKey) {
|
||||||
|
return new StateAction(
|
||||||
|
"system",
|
||||||
|
"dynamic-action-cleanup:" + actionKey,
|
||||||
|
"清理临时动态行动",
|
||||||
|
Schedulable.ScheduleType.ONCE,
|
||||||
|
ZonedDateTime.now().plusSeconds(TEMP_ACTION_TTL_MILLIS / 1000).toString(),
|
||||||
|
new StateAction.Trigger.Call(() -> {
|
||||||
|
removeTempDynamicAction(actionKey);
|
||||||
|
return Unit.INSTANCE;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void removeTempDynamicAction(String actionKey) {
|
||||||
|
TempDynamicActionRecord record = tempDynamicActions.remove(actionKey);
|
||||||
|
if (record == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
actionCapability.listAvailableMetaActions().remove(actionKey);
|
||||||
|
deleteTempFileQuietly(record.location());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void deleteTempFileQuietly(String location) {
|
||||||
|
try {
|
||||||
|
Files.deleteIfExists(Path.of(location));
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> copyStringMap(JSONObject jsonObject) {
|
||||||
|
if (jsonObject == null) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
Map<String, String> params = new LinkedHashMap<>();
|
||||||
|
jsonObject.forEach((key, value) -> params.put(key, value == null ? "" : String.valueOf(value)));
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<String> toOrderedSet(com.alibaba.fastjson2.JSONArray jsonArray) {
|
||||||
|
if (jsonArray == null) {
|
||||||
|
return Set.of();
|
||||||
|
}
|
||||||
|
return jsonArray.toJavaList(String.class).stream()
|
||||||
|
.filter(item -> item != null && !item.isBlank())
|
||||||
|
.collect(java.util.stream.Collectors.toCollection(java.util.LinkedHashSet::new));
|
||||||
|
}
|
||||||
|
|
||||||
|
private JSONObject copyJsonObject(JSONObject jsonObject) {
|
||||||
|
return jsonObject == null ? JSONObject.of() : JSONObject.from(jsonObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeCodeType(String codeType) {
|
||||||
|
return codeType.startsWith(".") ? codeType.substring(1) : codeType;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractFileBaseName(String location, String ext) {
|
||||||
|
String fileName = Path.of(location).getFileName().toString();
|
||||||
|
String suffix = "." + ext;
|
||||||
|
if (fileName.endsWith(suffix)) {
|
||||||
|
return fileName.substring(0, fileName.length() - suffix.length());
|
||||||
|
}
|
||||||
|
return fileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String shortUuid() {
|
||||||
|
return UUID.randomUUID().toString().replace("-", "").substring(0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
private record TempDynamicActionRecord(
|
||||||
|
String actionKey,
|
||||||
|
String location,
|
||||||
|
String cleanupActionId,
|
||||||
|
ActionFileMetaData fileMetaData,
|
||||||
|
MetaActionInfo metaActionInfo
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package work.slhaf.partner.module.modules.action.builtin;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
|
import org.junit.jupiter.api.Assertions;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
import work.slhaf.partner.core.action.ActionCapability;
|
||||||
|
import work.slhaf.partner.core.action.entity.ActionFileMetaData;
|
||||||
|
import work.slhaf.partner.core.action.entity.MetaAction;
|
||||||
|
import work.slhaf.partner.core.action.entity.MetaActionInfo;
|
||||||
|
import work.slhaf.partner.core.action.entity.StateAction;
|
||||||
|
import work.slhaf.partner.core.action.runner.RunnerClient;
|
||||||
|
import work.slhaf.partner.module.modules.action.scheduler.ActionScheduler;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
|
||||||
|
class BuiltinDynamicActionProviderTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path tempDir;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void generateShouldRegisterTempOriginActionAndScheduleCleanup() throws Exception {
|
||||||
|
TestContext context = createContext();
|
||||||
|
BuiltinActionRegistry.BuiltinActionDefinition generate = requireDefinition(context.provider.provideBuiltinActions(), "builtin::dynamic::generate");
|
||||||
|
|
||||||
|
JSONObject result = JSONObject.parseObject(generate.invoker().apply(Map.of(
|
||||||
|
"desc", "temporary origin",
|
||||||
|
"code", "print('ok')",
|
||||||
|
"codeType", "py",
|
||||||
|
"launcher", "python3",
|
||||||
|
"meta", """
|
||||||
|
{
|
||||||
|
"io": true,
|
||||||
|
"params": {"input": "user input"},
|
||||||
|
"tags": ["dynamic", "temp"],
|
||||||
|
"preActions": ["builtin::command::execute"],
|
||||||
|
"postActions": ["builtin::dynamic::persist"],
|
||||||
|
"strictDependencies": true,
|
||||||
|
"responseSchema": {"result": "dynamic result"}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)));
|
||||||
|
|
||||||
|
String actionKey = result.getString("actionKey");
|
||||||
|
Assertions.assertTrue(result.getBooleanValue("ok"));
|
||||||
|
Assertions.assertTrue(actionKey.startsWith("origin::"));
|
||||||
|
|
||||||
|
MetaActionInfo metaActionInfo = context.availableMetaActions.get(actionKey);
|
||||||
|
Assertions.assertNotNull(metaActionInfo);
|
||||||
|
Assertions.assertEquals("python3", metaActionInfo.getLauncher());
|
||||||
|
Assertions.assertEquals("temporary origin", metaActionInfo.getDescription());
|
||||||
|
Assertions.assertTrue(metaActionInfo.getIo());
|
||||||
|
Assertions.assertEquals("user input", metaActionInfo.getParams().get("input"));
|
||||||
|
Assertions.assertTrue(metaActionInfo.getTags().contains("dynamic"));
|
||||||
|
Assertions.assertTrue(metaActionInfo.getStrictDependencies());
|
||||||
|
Assertions.assertEquals("dynamic result", metaActionInfo.getResponseSchema().getString("result"));
|
||||||
|
|
||||||
|
Path tempFile = Path.of(actionKey.substring("origin::".length()));
|
||||||
|
Assertions.assertTrue(Files.exists(tempFile));
|
||||||
|
|
||||||
|
ArgumentCaptor<StateAction> cleanupCaptor = ArgumentCaptor.forClass(StateAction.class);
|
||||||
|
Mockito.verify(context.actionScheduler).schedule(cleanupCaptor.capture());
|
||||||
|
Assertions.assertEquals("清理临时动态行动", cleanupCaptor.getValue().getDescription());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void persistShouldPersistDeleteTempFileAndCancelCleanup() throws Exception {
|
||||||
|
TestContext context = createContext();
|
||||||
|
BuiltinActionRegistry.BuiltinActionDefinition generate = requireDefinition(context.provider.provideBuiltinActions(), "builtin::dynamic::generate");
|
||||||
|
BuiltinActionRegistry.BuiltinActionDefinition persist = requireDefinition(context.provider.provideBuiltinActions(), "builtin::dynamic::persist");
|
||||||
|
|
||||||
|
String actionKey = JSONObject.parseObject(generate.invoker().apply(Map.of(
|
||||||
|
"desc", "persist target",
|
||||||
|
"code", "print('persist')",
|
||||||
|
"codeType", ".py",
|
||||||
|
"launcher", "python3",
|
||||||
|
"meta", "{}"
|
||||||
|
))).getString("actionKey");
|
||||||
|
Path tempFile = Path.of(actionKey.substring("origin::".length()));
|
||||||
|
|
||||||
|
ArgumentCaptor<StateAction> cleanupCaptor = ArgumentCaptor.forClass(StateAction.class);
|
||||||
|
Mockito.verify(context.actionScheduler).schedule(cleanupCaptor.capture());
|
||||||
|
StateAction cleanupAction = cleanupCaptor.getValue();
|
||||||
|
|
||||||
|
JSONObject persistResult = JSONObject.parseObject(persist.invoker().apply(Map.of("actionKey", actionKey)));
|
||||||
|
|
||||||
|
Assertions.assertTrue(persistResult.getBooleanValue("ok"));
|
||||||
|
Assertions.assertEquals(actionKey, persistResult.getString("actionKey"));
|
||||||
|
Assertions.assertTrue(context.runnerClient.persistCalled);
|
||||||
|
Assertions.assertFalse(Files.exists(tempFile));
|
||||||
|
Assertions.assertFalse(context.availableMetaActions.containsKey(actionKey));
|
||||||
|
Mockito.verify(context.actionScheduler).cancel(cleanupAction.getUuid());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cleanupActionShouldDeleteTempFileAndRemoveMetaAction() throws Exception {
|
||||||
|
TestContext context = createContext();
|
||||||
|
BuiltinActionRegistry.BuiltinActionDefinition generate = requireDefinition(context.provider.provideBuiltinActions(), "builtin::dynamic::generate");
|
||||||
|
|
||||||
|
String actionKey = JSONObject.parseObject(generate.invoker().apply(Map.of(
|
||||||
|
"desc", "cleanup target",
|
||||||
|
"code", "print('cleanup')",
|
||||||
|
"codeType", "py",
|
||||||
|
"launcher", "python3",
|
||||||
|
"meta", "{}"
|
||||||
|
))).getString("actionKey");
|
||||||
|
Path tempFile = Path.of(actionKey.substring("origin::".length()));
|
||||||
|
|
||||||
|
ArgumentCaptor<StateAction> cleanupCaptor = ArgumentCaptor.forClass(StateAction.class);
|
||||||
|
Mockito.verify(context.actionScheduler).schedule(cleanupCaptor.capture());
|
||||||
|
cleanupCaptor.getValue().getTrigger().onTrigger();
|
||||||
|
|
||||||
|
Assertions.assertFalse(Files.exists(tempFile));
|
||||||
|
Assertions.assertFalse(context.availableMetaActions.containsKey(actionKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
private TestContext createContext() throws Exception {
|
||||||
|
BuiltinDynamicActionProvider provider = new BuiltinDynamicActionProvider();
|
||||||
|
ActionCapability actionCapability = Mockito.mock(ActionCapability.class);
|
||||||
|
ActionScheduler actionScheduler = Mockito.mock(ActionScheduler.class);
|
||||||
|
Map<String, MetaActionInfo> availableMetaActions = new LinkedHashMap<>();
|
||||||
|
TestRunnerClient runnerClient = new TestRunnerClient(tempDir);
|
||||||
|
|
||||||
|
Mockito.when(actionCapability.runnerClient()).thenReturn(runnerClient);
|
||||||
|
Mockito.when(actionCapability.listAvailableMetaActions()).thenReturn(availableMetaActions);
|
||||||
|
Mockito.doAnswer(invocation -> {
|
||||||
|
availableMetaActions.putAll(invocation.getArgument(0));
|
||||||
|
return null;
|
||||||
|
}).when(actionCapability).registerMetaActions(Mockito.anyMap());
|
||||||
|
|
||||||
|
inject(provider, "actionCapability", actionCapability);
|
||||||
|
inject(provider, "actionScheduler", actionScheduler);
|
||||||
|
return new TestContext(provider, actionScheduler, availableMetaActions, runnerClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
private BuiltinActionRegistry.BuiltinActionDefinition requireDefinition(
|
||||||
|
List<BuiltinActionRegistry.BuiltinActionDefinition> definitions,
|
||||||
|
String actionKey
|
||||||
|
) {
|
||||||
|
return definitions.stream()
|
||||||
|
.filter(definition -> actionKey.equals(definition.actionKey()))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(() -> new AssertionError("definition not found: " + actionKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void inject(Object target, String fieldName, Object value) throws Exception {
|
||||||
|
Field field = target.getClass().getDeclaredField(fieldName);
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(target, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private record TestContext(
|
||||||
|
BuiltinDynamicActionProvider provider,
|
||||||
|
ActionScheduler actionScheduler,
|
||||||
|
Map<String, MetaActionInfo> availableMetaActions,
|
||||||
|
TestRunnerClient runnerClient
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class TestRunnerClient extends RunnerClient {
|
||||||
|
private final Path tempBase;
|
||||||
|
private boolean persistCalled;
|
||||||
|
|
||||||
|
private TestRunnerClient(Path tempBase) {
|
||||||
|
super(new ConcurrentHashMap<>(), Executors.newSingleThreadExecutor(), tempBase.toString());
|
||||||
|
this.tempBase = tempBase;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected RunnerResponse doRun(MetaAction metaAction) {
|
||||||
|
return new RunnerResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String buildTmpPath(String actionKey, String codeType) {
|
||||||
|
String normalized = codeType.startsWith(".") ? codeType : "." + codeType;
|
||||||
|
return tempBase.resolve(actionKey + normalized).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void tmpSerialize(MetaAction tempAction, String code, String codeType) throws IOException {
|
||||||
|
Path path = Path.of(tempAction.getLocation());
|
||||||
|
if (path.getParent() != null) {
|
||||||
|
Files.createDirectories(path.getParent());
|
||||||
|
}
|
||||||
|
Files.writeString(path, code);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void persistSerialize(MetaActionInfo metaActionInfo, ActionFileMetaData fileMetaData) {
|
||||||
|
persistCalled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user