diff --git a/Partner-Core/src/main/java/work/slhaf/partner/core/action/ActionCore.java b/Partner-Core/src/main/java/work/slhaf/partner/core/action/ActionCore.java index 0c05e82a..6cd7adab 100644 --- a/Partner-Core/src/main/java/work/slhaf/partner/core/action/ActionCore.java +++ b/Partner-Core/src/main/java/work/slhaf/partner/core/action/ActionCore.java @@ -1,10 +1,11 @@ package work.slhaf.partner.core.action; +import com.alibaba.fastjson2.JSONObject; import lombok.NonNull; import lombok.extern.slf4j.Slf4j; import lombok.val; +import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import work.slhaf.partner.core.PartnerCore; import work.slhaf.partner.core.action.entity.ExecutableAction; import work.slhaf.partner.core.action.entity.MetaAction; import work.slhaf.partner.core.action.entity.MetaActionInfo; @@ -16,8 +17,12 @@ import work.slhaf.partner.core.action.runner.RunnerClient; import work.slhaf.partner.framework.agent.config.ConfigCenter; import work.slhaf.partner.framework.agent.factory.capability.annotation.CapabilityCore; import work.slhaf.partner.framework.agent.factory.capability.annotation.CapabilityMethod; +import work.slhaf.partner.framework.agent.state.State; +import work.slhaf.partner.framework.agent.state.StateSerializable; +import work.slhaf.partner.framework.agent.state.StateValue; import java.io.IOException; +import java.nio.file.Path; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArraySet; @@ -28,7 +33,7 @@ import java.util.stream.Collectors; @SuppressWarnings("FieldMayBeFinal") @CapabilityCore(value = "action") @Slf4j -public class ActionCore extends PartnerCore { +public class ActionCore implements StateSerializable { public static final String BUILTIN_LOCATION = "builtin"; public static final String ORIGIN_LOCATION = "origin"; @@ -51,6 +56,7 @@ public class ActionCore extends PartnerCore { String baseActionPath = ConfigCenter.INSTANCE.getPaths().getResourcesDir().resolve("action").normalize().toAbsolutePath().toString(); // TODO 通过 Config 指定采用何种 runnerClient,当前只提供 LocalRunnerClient runnerClient = new LocalRunnerClient(existedMetaActions, virtualExecutor, baseActionPath); + register(); setupShutdownHook(); } @@ -231,8 +237,21 @@ public class ActionCore extends PartnerCore { } @Override - protected String getCoreKey() { - return "action-core"; + public @NotNull Path statePath() { + return Path.of("core", "action.json"); + } + + @Override + public void load(@NotNull JSONObject state) { + actionPool = ActionPoolStateCodec.decode(state.getJSONArray("action_pool")); + } + + @Override + public @NotNull State convert() { + State state = new State(); + List actionPoolState = ActionPoolStateCodec.encode(actionPool); + state.append("action_pool", StateValue.arr(actionPoolState)); + return state; } public enum ExecutorType { diff --git a/Partner-Core/src/main/java/work/slhaf/partner/core/action/ActionPoolStateCodec.java b/Partner-Core/src/main/java/work/slhaf/partner/core/action/ActionPoolStateCodec.java new file mode 100644 index 00000000..d5729af2 --- /dev/null +++ b/Partner-Core/src/main/java/work/slhaf/partner/core/action/ActionPoolStateCodec.java @@ -0,0 +1,341 @@ +package work.slhaf.partner.core.action; + +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.Nullable; +import work.slhaf.partner.core.action.entity.*; +import work.slhaf.partner.framework.agent.state.StateValue; +import work.slhaf.partner.module.action.executor.entity.HistoryAction; + +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArraySet; + +@Slf4j +final class ActionPoolStateCodec { + + private ActionPoolStateCodec() { + } + + static List encode(CopyOnWriteArraySet actionPool) { + return actionPool.stream() + .map(ActionPoolStateCodec::encodeExecutableAction) + .toList(); + } + + static CopyOnWriteArraySet decode(@Nullable JSONArray actionPoolArray) { + CopyOnWriteArraySet restored = new CopyOnWriteArraySet<>(); + if (actionPoolArray == null) { + return restored; + } + for (int i = 0; i < actionPoolArray.size(); i++) { + JSONObject actionObject = actionPoolArray.getJSONObject(i); + if (actionObject == null) { + continue; + } + try { + ExecutableAction executableAction = decodeExecutableAction(actionObject); + if (executableAction != null) { + restored.add(executableAction); + } + } catch (Exception e) { + log.warn("Skip invalid action_pool item at index {}", i, e); + } + } + return restored; + } + + private static StateValue.Obj encodeExecutableAction(ExecutableAction action) { + Map actionMap = new LinkedHashMap<>(); + actionMap.put("kind", StateValue.str(action instanceof SchedulableExecutableAction ? "schedulable" : "immediate")); + actionMap.put("uuid", StateValue.str(action.getUuid())); + actionMap.put("source", StateValue.str(action.getSource())); + actionMap.put("reason", StateValue.str(action.getReason())); + actionMap.put("description", StateValue.str(action.getDescription())); + actionMap.put("status", StateValue.str(action.getStatus().name())); + actionMap.put("tendency", StateValue.str(action.getTendency())); + actionMap.put("executing_stage", StateValue.num(action.getExecutingStage())); + + String result = resolveExecutableResult(action); + if (result != null) { + actionMap.put("result", StateValue.str(result)); + } + if (action instanceof SchedulableExecutableAction schedulableAction) { + actionMap.put("schedule_type", StateValue.str(schedulableAction.getScheduleType().name())); + actionMap.put("schedule_content", StateValue.str(schedulableAction.getScheduleContent())); + actionMap.put("enabled", StateValue.bool(schedulableAction.getEnabled())); + actionMap.put("schedule_histories", StateValue.arr(encodeScheduleHistories(schedulableAction))); + } + + List chainStates = action.getActionChain().entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .map(entry -> { + Map stageMap = new LinkedHashMap<>(); + stageMap.put("stage", StateValue.num(entry.getKey())); + stageMap.put("actions", StateValue.arr(entry.getValue().stream() + .map(metaAction -> (StateValue) encodeMetaAction(metaAction)) + .toList())); + return StateValue.obj(stageMap); + }).toList(); + actionMap.put("action_chain", StateValue.arr(chainStates)); + + actionMap.put("history", StateValue.arr(encodeHistoryStages(action.getHistory()))); + + return StateValue.obj(actionMap); + } + + private static StateValue.Obj encodeMetaAction(MetaAction metaAction) { + Map metaMap = new LinkedHashMap<>(); + metaMap.put("name", StateValue.str(metaAction.getName())); + metaMap.put("io", StateValue.bool(metaAction.getIo())); + if (metaAction.getLauncher() != null) { + metaMap.put("launcher", StateValue.str(metaAction.getLauncher())); + } + metaMap.put("type", StateValue.str(metaAction.getType().name())); + metaMap.put("location", StateValue.str(metaAction.getLocation())); + metaMap.put("params_json", StateValue.str(JSONObject.toJSONString(metaAction.getParams()))); + metaMap.put("result_status", StateValue.str(metaAction.getResult().getStatus().name())); + if (metaAction.getResult().getData() != null) { + metaMap.put("result_data", StateValue.str(metaAction.getResult().getData())); + } + return StateValue.obj(metaMap); + } + + private static StateValue.Obj encodeHistoryAction(HistoryAction historyAction) { + Map historyMap = new LinkedHashMap<>(); + historyMap.put("action_key", StateValue.str(historyAction.actionKey())); + historyMap.put("description", StateValue.str(historyAction.description())); + historyMap.put("result", StateValue.str(historyAction.result())); + return StateValue.obj(historyMap); + } + + private static ExecutableAction decodeExecutableAction(JSONObject actionObject) { + String kind = actionObject.getString("kind"); + String uuid = actionObject.getString("uuid"); + String source = actionObject.getString("source"); + String reason = actionObject.getString("reason"); + String description = actionObject.getString("description"); + String tendency = actionObject.getString("tendency"); + String status = actionObject.getString("status"); + Integer executingStage = actionObject.getInteger("executing_stage"); + if (kind == null || uuid == null || source == null || reason == null || description == null || tendency == null) { + return null; + } + + Map> restoredChain = decodeActionChain(actionObject.getJSONArray("action_chain")); + ExecutableAction executableAction; + if ("schedulable".equals(kind)) { + String scheduleType = actionObject.getString("schedule_type"); + String scheduleContent = actionObject.getString("schedule_content"); + if (scheduleType == null || scheduleContent == null) { + return null; + } + SchedulableExecutableAction schedulableAction = new SchedulableExecutableAction( + tendency, + restoredChain, + reason, + description, + source, + Schedulable.ScheduleType.valueOf(scheduleType), + scheduleContent, + uuid + ); + Boolean enabled = actionObject.getBoolean("enabled"); + if (enabled != null) { + schedulableAction.setEnabled(enabled); + } + schedulableAction.getScheduleHistories().addAll(decodeScheduleHistories(actionObject.getJSONArray("schedule_histories"))); + executableAction = schedulableAction; + } else if ("immediate".equals(kind)) { + executableAction = new ImmediateExecutableAction( + tendency, + restoredChain, + reason, + description, + source, + uuid + ); + } else { + return null; + } + + if (status != null) { + executableAction.setStatus(Action.Status.valueOf(status)); + } + if (executingStage != null) { + executableAction.setExecutingStage(executingStage); + } + String result = actionObject.getString("result"); + if (result != null) { + executableAction.setResult(result); + } + executableAction.getHistory().putAll(decodeHistory(actionObject.getJSONArray("history"))); + return executableAction; + } + + private static Map> decodeActionChain(@Nullable JSONArray actionChainArray) { + Map> restored = new LinkedHashMap<>(); + if (actionChainArray == null) { + return toMutableActionChain(restored); + } + for (int i = 0; i < actionChainArray.size(); i++) { + JSONObject stageObject = actionChainArray.getJSONObject(i); + if (stageObject == null) { + continue; + } + Integer stage = stageObject.getInteger("stage"); + JSONArray actions = stageObject.getJSONArray("actions"); + if (stage == null || actions == null) { + continue; + } + List metaActions = new ArrayList<>(); + for (int j = 0; j < actions.size(); j++) { + JSONObject actionObject = actions.getJSONObject(j); + MetaAction metaAction = decodeMetaAction(actionObject); + if (metaAction != null) { + metaActions.add(metaAction); + } + } + restored.put(stage, metaActions); + } + return toMutableActionChain(restored); + } + + private static MetaAction decodeMetaAction(@Nullable JSONObject actionObject) { + if (actionObject == null) { + return null; + } + String name = actionObject.getString("name"); + Boolean io = actionObject.getBoolean("io"); + String type = actionObject.getString("type"); + String location = actionObject.getString("location"); + if (name == null || io == null || type == null || location == null) { + return null; + } + MetaAction metaAction = new MetaAction( + name, + io, + actionObject.getString("launcher"), + MetaAction.Type.valueOf(type), + location + ); + String paramsJson = actionObject.getString("params_json"); + if (paramsJson != null && !paramsJson.isBlank()) { + JSONObject paramsObject = JSONObject.parseObject(paramsJson); + if (paramsObject != null) { + metaAction.getParams().putAll(paramsObject); + } + } + String resultStatus = actionObject.getString("result_status"); + if (resultStatus != null) { + metaAction.getResult().setStatus(MetaAction.Result.Status.valueOf(resultStatus)); + } + metaAction.getResult().setData(actionObject.getString("result_data")); + return metaAction; + } + + private static Map> decodeHistory(@Nullable JSONArray historyArray) { + Map> restored = new LinkedHashMap<>(); + if (historyArray == null) { + return restored; + } + for (int i = 0; i < historyArray.size(); i++) { + JSONObject stageObject = historyArray.getJSONObject(i); + if (stageObject == null) { + continue; + } + Integer stage = stageObject.getInteger("stage"); + JSONArray actions = stageObject.getJSONArray("actions"); + if (stage == null || actions == null) { + continue; + } + List historyActions = new ArrayList<>(); + for (int j = 0; j < actions.size(); j++) { + JSONObject historyObject = actions.getJSONObject(j); + if (historyObject == null) { + continue; + } + String actionKey = historyObject.getString("action_key"); + String description = historyObject.getString("description"); + String result = historyObject.getString("result"); + if (actionKey == null || description == null || result == null) { + continue; + } + historyActions.add(new HistoryAction(actionKey, description, result)); + } + restored.put(stage, historyActions); + } + return restored; + } + + private static List encodeHistoryStages(Map> historyMap) { + return historyMap.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .map(entry -> { + Map stageMap = new LinkedHashMap<>(); + stageMap.put("stage", StateValue.num(entry.getKey())); + stageMap.put("actions", StateValue.arr(entry.getValue().stream() + .map(historyAction -> (StateValue) encodeHistoryAction(historyAction)) + .toList())); + return StateValue.obj(stageMap); + }).toList(); + } + + private static List encodeScheduleHistories(SchedulableExecutableAction schedulableAction) { + return schedulableAction.getScheduleHistories().stream() + .map(scheduleHistory -> { + Map historyMap = new LinkedHashMap<>(); + historyMap.put("end_time", StateValue.str(scheduleHistory.getEndTime().toString())); + historyMap.put("result", StateValue.str(scheduleHistory.getResult())); + historyMap.put("history", StateValue.arr(encodeHistoryStages(scheduleHistory.getHistory()))); + return StateValue.obj(historyMap); + }) + .toList(); + } + + private static List decodeScheduleHistories(@Nullable JSONArray scheduleHistoriesArray) { + List restored = new ArrayList<>(); + if (scheduleHistoriesArray == null) { + return restored; + } + for (int i = 0; i < scheduleHistoriesArray.size(); i++) { + JSONObject historyObject = scheduleHistoriesArray.getJSONObject(i); + if (historyObject == null) { + continue; + } + try { + String endTime = historyObject.getString("end_time"); + String result = historyObject.getString("result"); + if (endTime == null || result == null) { + continue; + } + restored.add(new SchedulableExecutableAction.ScheduleHistory( + ZonedDateTime.parse(endTime), + result, + decodeHistory(historyObject.getJSONArray("history")) + )); + } catch (Exception e) { + log.warn("Skip invalid schedule_history item at index {}", i, e); + } + } + return restored; + } + + private static Map> toMutableActionChain(Map> actionChain) { + Map> restored = new LinkedHashMap<>(); + actionChain.forEach((stage, actions) -> restored.put(stage, new ArrayList<>(actions))); + return restored; + } + + private static String resolveExecutableResult(ExecutableAction action) { + try { + return action.getResult(); + } catch (RuntimeException ignored) { + return null; + } + } +} diff --git a/Partner-Core/src/main/java/work/slhaf/partner/core/action/entity/Action.kt b/Partner-Core/src/main/java/work/slhaf/partner/core/action/entity/Action.kt index d947fe82..4ae78e1b 100644 --- a/Partner-Core/src/main/java/work/slhaf/partner/core/action/entity/Action.kt +++ b/Partner-Core/src/main/java/work/slhaf/partner/core/action/entity/Action.kt @@ -7,15 +7,12 @@ import java.util.* import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes -sealed class Action { +sealed class Action( + open val uuid: String = UUID.randomUUID().toString() +) { /** * 行动ID */ - val uuid: String = UUID.randomUUID().toString() - - /** - * 行动来源 - */ abstract val source: String /** @@ -84,7 +81,9 @@ sealed interface Schedulable { /** * 行动模块传递的行动数据,包含行动uuid、倾向、状态、行动链、结果、发起原因、行动描述等信息。 */ -sealed class ExecutableAction : Action() { +sealed class ExecutableAction( + override val uuid: String = UUID.randomUUID().toString() +) : Action(uuid) { /** * 行动倾向 */ @@ -155,7 +154,7 @@ sealed class ExecutableAction : Action() { /** * 计划行动数据类,继承自[Action],扩展了[Schedulable]相关调度属性,用于标识计划类型(单次还是周期性任务)和计划内容 */ -data class SchedulableExecutableAction( +data class SchedulableExecutableAction @JvmOverloads constructor( override val tendency: String, override val actionChain: MutableMap>, override val reason: String, @@ -163,7 +162,8 @@ data class SchedulableExecutableAction( override val source: String, override val scheduleType: Schedulable.ScheduleType, override val scheduleContent: String, -) : ExecutableAction(), Schedulable { + override val uuid: String = UUID.randomUUID().toString(), +) : ExecutableAction(uuid), Schedulable { override var enabled = true val scheduleHistories = ArrayList() @@ -191,13 +191,14 @@ data class SchedulableExecutableAction( /** * 即时行动数据类 */ -data class ImmediateExecutableAction( +data class ImmediateExecutableAction @JvmOverloads constructor( override val tendency: String, override val actionChain: MutableMap>, override val reason: String, override val description: String, override val source: String, -) : ExecutableAction() + override val uuid: String = UUID.randomUUID().toString(), +) : ExecutableAction(uuid) /** * 用于计时的一次性或周期性触发或者针对某一数据源进行内容更新的行动 @@ -284,4 +285,4 @@ data class StateActionSnapshot( val scheduleType: Schedulable.ScheduleType, val scheduleContent: String, val enabled: Boolean -) \ No newline at end of file +) diff --git a/Partner-Core/src/test/java/work/slhaf/partner/core/action/ActionCoreTest.java b/Partner-Core/src/test/java/work/slhaf/partner/core/action/ActionCoreTest.java new file mode 100644 index 00000000..af2516e0 --- /dev/null +++ b/Partner-Core/src/test/java/work/slhaf/partner/core/action/ActionCoreTest.java @@ -0,0 +1,334 @@ +package work.slhaf.partner.core.action; + +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import work.slhaf.partner.core.action.entity.*; +import work.slhaf.partner.module.action.executor.entity.HistoryAction; + +import java.nio.file.Path; +import java.time.ZonedDateTime; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; + +class ActionCoreTest { + + private static ActionCore actionCore; + + @BeforeAll + static void beforeAll(@TempDir Path tempDir) throws Exception { + System.setProperty("user.home", tempDir.toAbsolutePath().toString()); + actionCore = new ActionCore(); + } + + private static JSONObject buildImmediateActionJson() { + return JSONObject.of( + "kind", "immediate", + "uuid", "immediate-uuid", + "source", "planner", + "reason", "immediate-reason", + "description", "run immediately", + "status", "EXECUTING", + "tendency", "urgent", + "executing_stage", 1, + "result", "immediate-result", + "action_chain", JSONArray.of( + JSONObject.of( + "stage", 1, + "actions", JSONArray.of( + JSONObject.of( + "name", "command", + "io", true, + "type", "BUILTIN", + "location", "builtin", + "params_json", "{\"name\":\"demo\",\"count\":2}", + "result_status", "SUCCESS", + "result_data", "done" + ) + ) + ) + ), + "history", JSONArray.of( + JSONObject.of( + "stage", 1, + "actions", JSONArray.of( + JSONObject.of( + "action_key", "builtin::command", + "description", "command finished", + "result", "done" + ) + ) + ) + ) + ); + } + + private static JSONObject buildSchedulableActionJson() { + return JSONObject.of( + "kind", "schedulable", + "uuid", "schedulable-uuid", + "source", "scheduler", + "reason", "sched-summary", + "description", "refresh state", + "status", "PREPARE", + "tendency", "steady", + "executing_stage", 0, + "schedule_type", "CYCLE", + "schedule_content", "0 0/5 * * * ?", + "enabled", false, + "action_chain", JSONArray.of( + JSONObject.of( + "stage", 2, + "actions", JSONArray.of( + JSONObject.of( + "name", "refresh", + "io", false, + "launcher", "bash", + "type", "ORIGIN", + "location", "origin", + "params_json", "{\"interval\":\"5m\"}", + "result_status", "WAITING" + ) + ) + ) + ), + "history", JSONArray.of(), + "schedule_histories", JSONArray.of( + JSONObject.of( + "end_time", "2026-04-07T09:30:00+08:00[Asia/Shanghai]", + "result", "cycle-result", + "history", JSONArray.of( + JSONObject.of( + "stage", 3, + "actions", JSONArray.of( + JSONObject.of( + "action_key", "origin::refresh", + "description", "refresh finished", + "result", "ok" + ) + ) + ) + ) + ) + ) + ); + } + + private static Map> actionChain(MetaAction metaAction) { + Map> actionChain = new LinkedHashMap<>(); + actionChain.put(1, new ArrayList<>(List.of(metaAction))); + return actionChain; + } + + private static MetaAction metaAction(String name, + MetaAction.Type type, + String location, + Map params, + MetaAction.Result.Status status, + String resultData) { + MetaAction metaAction = new MetaAction(name, true, null, type, location); + metaAction.getParams().putAll(params); + metaAction.getResult().setStatus(status); + metaAction.getResult().setData(resultData); + return metaAction; + } + + @BeforeEach + void setUp() { + actionCore.load(JSONObject.of("action_pool", new JSONArray())); + } + + @Test + void shouldLoadActionPoolAndPreserveUuid() { + actionCore.load(JSONObject.of("action_pool", JSONArray.of( + buildImmediateActionJson(), + buildSchedulableActionJson() + ))); + + Set actions = actionCore.listActions(null, null); + assertEquals(2, actions.size()); + + work.slhaf.partner.core.action.entity.ExecutableAction immediate = actions.stream() + .filter(action -> "immediate-uuid".equals(action.getUuid())) + .findFirst() + .orElseThrow(); + assertInstanceOf(ImmediateExecutableAction.class, immediate); + assertEquals(Action.Status.EXECUTING, immediate.getStatus()); + assertEquals(1, immediate.getExecutingStage()); + assertEquals("immediate-result", immediate.getResult()); + assertEquals(1, immediate.getActionChain().size()); + MetaAction firstMetaAction = immediate.getActionChain().get(1).getFirst(); + assertEquals("builtin::command", firstMetaAction.getKey()); + assertEquals("demo", firstMetaAction.getParams().get("name")); + assertEquals(2, firstMetaAction.getParams().get("count")); + assertEquals(MetaAction.Result.Status.SUCCESS, firstMetaAction.getResult().getStatus()); + assertEquals("done", firstMetaAction.getResult().getData()); + HistoryAction historyAction = immediate.getHistory().get(1).getFirst(); + assertEquals("builtin::command", historyAction.actionKey()); + assertEquals("command finished", historyAction.description()); + assertEquals("done", historyAction.result()); + + work.slhaf.partner.core.action.entity.ExecutableAction schedulable = actions.stream() + .filter(action -> "schedulable-uuid".equals(action.getUuid())) + .findFirst() + .orElseThrow(); + SchedulableExecutableAction schedulableAction = assertInstanceOf(SchedulableExecutableAction.class, schedulable); + assertEquals(Action.Status.PREPARE, schedulableAction.getStatus()); + assertEquals("0 0/5 * * * ?", schedulableAction.getScheduleContent()); + assertEquals(Schedulable.ScheduleType.CYCLE, schedulableAction.getScheduleType()); + assertFalse(schedulableAction.getEnabled()); + assertEquals("sched-summary", schedulableAction.getReason()); + assertEquals(1, schedulableAction.getScheduleHistories().size()); + SchedulableExecutableAction.ScheduleHistory scheduleHistory = schedulableAction.getScheduleHistories().getFirst(); + assertEquals(ZonedDateTime.parse("2026-04-07T09:30:00+08:00[Asia/Shanghai]"), scheduleHistory.getEndTime()); + assertEquals("cycle-result", scheduleHistory.getResult()); + HistoryAction scheduledHistoryAction = scheduleHistory.getHistory().get(3).getFirst(); + assertEquals("origin::refresh", scheduledHistoryAction.actionKey()); + assertEquals("refresh finished", scheduledHistoryAction.description()); + assertEquals("ok", scheduledHistoryAction.result()); + } + + @Test + void shouldConvertActionPoolToState() { + ImmediateExecutableAction immediateAction = new ImmediateExecutableAction( + "urgent", + actionChain(metaAction("command", MetaAction.Type.BUILTIN, "builtin", Map.of("name", "demo"), MetaAction.Result.Status.SUCCESS, "done")), + "immediate-reason", + "run immediately", + "planner", + "immediate-uuid" + ); + immediateAction.setStatus(Action.Status.EXECUTING); + immediateAction.setExecutingStage(1); + immediateAction.setResult("immediate-result"); + immediateAction.getHistory().put(1, new ArrayList<>(List.of(new HistoryAction("builtin::command", "command finished", "done")))); + + SchedulableExecutableAction schedulableAction = new SchedulableExecutableAction( + "steady", + actionChain(metaAction("refresh", MetaAction.Type.ORIGIN, "origin", Map.of("interval", "5m"), MetaAction.Result.Status.WAITING, null)), + "sched-summary", + "refresh state", + "scheduler", + Schedulable.ScheduleType.CYCLE, + "0 0/5 * * * ?", + "schedulable-uuid" + ); + schedulableAction.setEnabled(false); + schedulableAction.setStatus(Action.Status.PREPARE); + schedulableAction.getScheduleHistories().add(new SchedulableExecutableAction.ScheduleHistory( + ZonedDateTime.parse("2026-04-07T09:30:00+08:00[Asia/Shanghai]"), + "cycle-result", + Map.of(3, List.of(new HistoryAction("origin::refresh", "refresh finished", "ok"))) + )); + + actionCore.putAction(immediateAction); + actionCore.putAction(schedulableAction); + + JSONObject state = JSONObject.parseObject(actionCore.convert().toString()); + JSONArray actionPool = state.getJSONArray("action_pool"); + assertNotNull(actionPool); + assertEquals(2, actionPool.size()); + + JSONObject immediateJson = actionPool.stream() + .map(JSONObject.class::cast) + .filter(item -> "immediate-uuid".equals(item.getString("uuid"))) + .findFirst() + .orElseThrow(); + assertEquals("immediate", immediateJson.getString("kind")); + assertEquals("planner", immediateJson.getString("source")); + assertEquals("urgent", immediateJson.getString("tendency")); + assertEquals("EXECUTING", immediateJson.getString("status")); + assertEquals(1, immediateJson.getIntValue("executing_stage")); + assertEquals("immediate-result", immediateJson.getString("result")); + JSONArray immediateChain = immediateJson.getJSONArray("action_chain"); + assertEquals(1, immediateChain.size()); + JSONObject immediateStage = immediateChain.getJSONObject(0); + assertEquals(1, immediateStage.getIntValue("stage")); + JSONObject immediateMeta = immediateStage.getJSONArray("actions").getJSONObject(0); + assertEquals("command", immediateMeta.getString("name")); + assertEquals("builtin", immediateMeta.getString("location")); + assertEquals("SUCCESS", immediateMeta.getString("result_status")); + assertEquals("done", immediateMeta.getString("result_data")); + assertEquals("{\"name\":\"demo\"}", immediateMeta.getString("params_json")); + + JSONObject schedulableJson = actionPool.stream() + .map(JSONObject.class::cast) + .filter(item -> "schedulable-uuid".equals(item.getString("uuid"))) + .findFirst() + .orElseThrow(); + assertEquals("schedulable", schedulableJson.getString("kind")); + assertEquals("CYCLE", schedulableJson.getString("schedule_type")); + assertEquals("0 0/5 * * * ?", schedulableJson.getString("schedule_content")); + assertFalse(schedulableJson.getBooleanValue("enabled")); + assertNull(schedulableJson.getString("result")); + JSONArray scheduleHistories = schedulableJson.getJSONArray("schedule_histories"); + assertNotNull(scheduleHistories); + assertEquals(1, scheduleHistories.size()); + JSONObject scheduleHistory = scheduleHistories.getJSONObject(0); + assertEquals("2026-04-07T09:30+08:00[Asia/Shanghai]", scheduleHistory.getString("end_time")); + assertEquals("cycle-result", scheduleHistory.getString("result")); + JSONObject scheduleStage = scheduleHistory.getJSONArray("history").getJSONObject(0); + assertEquals(3, scheduleStage.getIntValue("stage")); + JSONObject scheduledHistory = scheduleStage.getJSONArray("actions").getJSONObject(0); + assertEquals("origin::refresh", scheduledHistory.getString("action_key")); + assertEquals("refresh finished", scheduledHistory.getString("description")); + assertEquals("ok", scheduledHistory.getString("result")); + } + + @Test + void shouldResetToEmptyPoolWhenActionPoolMissing() { + actionCore.putAction(new ImmediateExecutableAction( + "urgent", + new LinkedHashMap<>(), + "reason", + "description", + "planner", + "transient-uuid" + )); + + actionCore.load(JSONObject.of()); + + assertTrue(actionCore.listActions(null, null).isEmpty()); + } + + @Test + void shouldSkipInvalidScheduleHistoryEntriesDuringLoad() { + JSONObject schedulableJson = buildSchedulableActionJson(); + schedulableJson.put("schedule_histories", JSONArray.of( + JSONObject.of( + "end_time", "2026-04-07T09:30:00+08:00[Asia/Shanghai]", + "result", "cycle-result", + "history", JSONArray.of( + JSONObject.of( + "stage", 3, + "actions", JSONArray.of( + JSONObject.of( + "action_key", "origin::refresh", + "description", "refresh finished", + "result", "ok" + ) + ) + ) + ) + ), + JSONObject.of( + "end_time", "bad-time", + "result", "broken", + "history", JSONArray.of() + ) + )); + + actionCore.load(JSONObject.of("action_pool", JSONArray.of(schedulableJson))); + + SchedulableExecutableAction schedulableAction = assertInstanceOf( + SchedulableExecutableAction.class, + actionCore.listActions(null, null).iterator().next() + ); + assertEquals(1, schedulableAction.getScheduleHistories().size()); + assertEquals("cycle-result", schedulableAction.getScheduleHistories().getFirst().getResult()); + } +}