001/*
002 * Copyright 2025 devteam@scivicslab.com
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing,
011 * software distributed under the License is distributed on an
012 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
013 * either express or implied.  See the License for the
014 * specific language governing permissions and limitations
015 * under the License.
016 */
017
018package com.scivicslab.actoriac.plugins.transitionviewer;
019
020import com.scivicslab.actoriac.log.DistributedLogStore;
021import com.scivicslab.pojoactor.core.ActionResult;
022import com.scivicslab.pojoactor.core.CallableByActionName;
023import com.scivicslab.pojoactor.workflow.ActorSystemAware;
024import com.scivicslab.pojoactor.workflow.IIActorRef;
025import com.scivicslab.pojoactor.workflow.IIActorSystem;
026
027import org.json.JSONObject;
028
029import java.sql.Connection;
030import java.sql.PreparedStatement;
031import java.sql.ResultSet;
032import java.sql.Timestamp;
033import java.time.format.DateTimeFormatter;
034import java.util.ArrayList;
035import java.util.LinkedHashMap;
036import java.util.List;
037import java.util.Map;
038import java.util.logging.Logger;
039
040/**
041 * ワークフロー実行時に記録されたTransition履歴を表示するプラグイン。
042 *
043 * <p>指定したアクター(ノードまたはNodeGroup)のTransition履歴を
044 * データベースから取得し、人間が読みやすい形式で出力する。</p>
045 *
046 * <h2>アクション:</h2>
047 * <ul>
048 *   <li>{@code showTransitions} - 指定アクターのTransition履歴を表示</li>
049 * </ul>
050 *
051 * <h2>使用例(ワークフロー):</h2>
052 * <pre>{@code
053 * - actor: loader
054 *   method: createChild
055 *   arguments: ["ROOT", "transitionViewer", "com.scivicslab.actoriac.plugins.transitionviewer.TransitionViewerPlugin"]
056 * - actor: transitionViewer
057 *   method: showTransitions
058 *   arguments:
059 *     target: "node-localhost"
060 * }</pre>
061 *
062 * @author devteam@scivicslab.com
063 * @since 2.15.0
064 */
065public class TransitionViewerPlugin implements CallableByActionName, ActorSystemAware {
066
067    private static final String CLASS_NAME = TransitionViewerPlugin.class.getName();
068    private static final Logger logger = Logger.getLogger(CLASS_NAME);
069    private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
070
071    private IIActorSystem system;
072    private Connection connection;
073
074    /**
075     * デフォルトコンストラクタ。loader.createChildで使用される。
076     */
077    public TransitionViewerPlugin() {
078        initConnection();
079    }
080
081    private void initConnection() {
082        DistributedLogStore logStore = DistributedLogStore.getInstance();
083        if (logStore != null) {
084            this.connection = logStore.getConnection();
085            logger.fine("TransitionViewerPlugin: Initialized database connection");
086        }
087    }
088
089    @Override
090    public void setActorSystem(IIActorSystem system) {
091        this.system = system;
092        logger.fine("TransitionViewerPlugin: ActorSystem set");
093    }
094
095    /**
096     * テスト用にデータベース接続を設定する。
097     */
098    public void setConnection(Connection connection) {
099        this.connection = connection;
100    }
101
102    @Override
103    public ActionResult callByActionName(String actionName, String args) {
104        logger.info("TransitionViewerPlugin.callByActionName: " + actionName);
105
106        return switch (actionName) {
107            case "showTransitions" -> showTransitions(args);
108            default -> new ActionResult(false, "Unknown action: " + actionName);
109        };
110    }
111
112    /**
113     * 指定されたアクターのTransition履歴を表示する。
114     *
115     * @param args JSON形式の引数
116     *   - target: アクター名(必須)
117     *   - session: セッションID(省略時は最新)
118     *   - includeChildren: trueの場合、配下ノードも含める
119     */
120    private ActionResult showTransitions(String args) {
121        if (connection == null) {
122            return new ActionResult(false, "Database connection not available");
123        }
124
125        String target;
126        long sessionId = -1;
127        boolean includeChildren = false;
128
129        try {
130            JSONObject json = new JSONObject(args);
131            target = json.getString("target");
132            sessionId = json.optLong("session", -1);
133            includeChildren = json.optBoolean("includeChildren", false);
134        } catch (Exception e) {
135            return new ActionResult(false, "Invalid arguments: " + e.getMessage() +
136                ". Expected: {\"target\": \"actor-name\", \"session\": optional, \"includeChildren\": optional}");
137        }
138
139        // セッションIDが指定されていなければnodeGroupから取得
140        if (sessionId < 0) {
141            try {
142                sessionId = getSessionIdFromNodeGroup();
143            } catch (Exception e) {
144                return new ActionResult(false, "Could not get session ID: " + e.getMessage());
145            }
146        }
147
148        try {
149            String output;
150            if (includeChildren && "nodeGroup".equals(target)) {
151                output = buildAggregatedOutput(sessionId);
152            } else {
153                output = buildSingleActorOutput(target, sessionId);
154            }
155
156            outputToMultiplexer(output);
157            return new ActionResult(true, output);
158
159        } catch (Exception e) {
160            logger.warning("TransitionViewerPlugin.showTransitions: " + e.getMessage());
161            return new ActionResult(false, "Error: " + e.getMessage());
162        }
163    }
164
165    /**
166     * 単一アクターのTransition履歴を構築する。
167     */
168    private String buildSingleActorOutput(String source, long sessionId) throws Exception {
169        List<TransitionEntry> entries = queryTransitions(source, sessionId);
170        SessionInfo sessionInfo = querySessionInfo(sessionId);
171
172        StringBuilder sb = new StringBuilder();
173        sb.append("=== Transition History ===\n");
174        if (sessionInfo.workflowName != null && !sessionInfo.workflowName.isEmpty()) {
175            sb.append("Workflow: ").append(sessionInfo.workflowName).append("\n");
176        }
177        if (sessionInfo.description != null && !sessionInfo.description.isEmpty()) {
178            sb.append("Description: ").append(sessionInfo.description).append("\n");
179        }
180        sb.append("Session: ").append(sessionId).append("\n");
181        sb.append("Target: ").append(source).append("\n\n");
182
183        int succeeded = 0;
184        int failed = 0;
185
186        for (TransitionEntry entry : entries) {
187            sb.append(entry.success ? "o " : "x ");
188            sb.append("[").append(entry.timestamp).append("] ");
189            sb.append(entry.label);
190            if (entry.note != null && !entry.note.isEmpty()) {
191                sb.append(" [").append(entry.note).append("]");
192            }
193            if (entry.success) {
194                sb.append("\n");
195                succeeded++;
196            } else {
197                if (entry.errorMessage != null && !entry.errorMessage.isEmpty()) {
198                    sb.append(" ").append(entry.errorMessage);
199                }
200                sb.append("\n");
201                failed++;
202            }
203        }
204
205        sb.append("\nSummary: ").append(entries.size()).append(" transitions, ");
206        sb.append(succeeded).append(" succeeded, ").append(failed).append(" failed");
207
208        return sb.toString();
209    }
210
211    /**
212     * NodeGroupと配下ノードの集約出力を構築する。
213     */
214    private String buildAggregatedOutput(long sessionId) throws Exception {
215        // セッション内の全アクターを取得
216        List<String> sources = queryDistinctSources(sessionId);
217        SessionInfo sessionInfo = querySessionInfo(sessionId);
218
219        StringBuilder sb = new StringBuilder();
220        sb.append("=== Transition History ===\n");
221        if (sessionInfo.workflowName != null && !sessionInfo.workflowName.isEmpty()) {
222            sb.append("Workflow: ").append(sessionInfo.workflowName).append("\n");
223        }
224        if (sessionInfo.description != null && !sessionInfo.description.isEmpty()) {
225            sb.append("Description: ").append(sessionInfo.description).append("\n");
226        }
227        sb.append("Session: ").append(sessionId).append("\n");
228        sb.append("Target: nodeGroup (with children)\n");
229
230        int totalTransitions = 0;
231        int totalSucceeded = 0;
232        int totalFailed = 0;
233
234        for (String source : sources) {
235            List<TransitionEntry> entries = queryTransitions(source, sessionId);
236            if (entries.isEmpty()) continue;
237
238            sb.append("\n[").append(source).append("]\n");
239
240            for (TransitionEntry entry : entries) {
241                sb.append("  ").append(entry.success ? "o " : "x ");
242                sb.append("[").append(entry.timestamp).append("] ");
243                sb.append(entry.label);
244                if (entry.note != null && !entry.note.isEmpty()) {
245                    sb.append(" [").append(entry.note).append("]");
246                }
247                if (entry.success) {
248                    sb.append("\n");
249                    totalSucceeded++;
250                } else {
251                    if (entry.errorMessage != null && !entry.errorMessage.isEmpty()) {
252                        sb.append(" ").append(entry.errorMessage);
253                    }
254                    sb.append("\n");
255                    totalFailed++;
256                }
257                totalTransitions++;
258            }
259        }
260
261        sb.append("\nSummary: ").append(totalTransitions).append(" transitions, ");
262        sb.append(totalSucceeded).append(" succeeded, ").append(totalFailed).append(" failed");
263
264        return sb.toString();
265    }
266
267    /**
268     * 指定アクターのTransitionログをクエリする。
269     */
270    private List<TransitionEntry> queryTransitions(String source, long sessionId) throws Exception {
271        List<TransitionEntry> entries = new ArrayList<>();
272
273        String sql = "SELECT timestamp, label, level, message FROM logs " +
274                     "WHERE session_id = ? AND node_id = ? AND message LIKE '%Transition %' " +
275                     "ORDER BY timestamp";
276
277        try (PreparedStatement ps = connection.prepareStatement(sql)) {
278            ps.setLong(1, sessionId);
279            ps.setString(2, source);
280
281            try (ResultSet rs = ps.executeQuery()) {
282                while (rs.next()) {
283                    Timestamp ts = rs.getTimestamp("timestamp");
284                    String label = rs.getString("label");
285                    String message = rs.getString("message");
286
287                    boolean success = message.contains("SUCCESS");
288                    String errorMessage = null;
289                    if (!success) {
290                        int dashIdx = message.indexOf(" - ");
291                        if (dashIdx > 0) {
292                            errorMessage = message.substring(dashIdx + 3);
293                        }
294                    }
295
296                    // messageから状態遷移とnoteを抽出
297                    String[] transitionAndNote = extractTransitionAndNote(message);
298                    String transition = transitionAndNote[0];
299                    String note = transitionAndNote[1];
300
301                    // labelが意味のある状態遷移でない場合はmessageから抽出した値を使用
302                    String displayLabel = (label != null && label.contains("->")) ? label : transition;
303
304                    String timeStr = ts.toLocalDateTime().format(TIME_FORMAT);
305                    entries.add(new TransitionEntry(timeStr, displayLabel, note, success, errorMessage));
306                }
307            }
308        }
309
310        return entries;
311    }
312
313    /**
314     * セッション内でTransitionを記録した全アクターを取得する。
315     */
316    private List<String> queryDistinctSources(long sessionId) throws Exception {
317        List<String> sources = new ArrayList<>();
318
319        String sql = "SELECT DISTINCT node_id FROM logs " +
320                     "WHERE session_id = ? AND message LIKE '%Transition %' " +
321                     "ORDER BY node_id";
322
323        try (PreparedStatement ps = connection.prepareStatement(sql)) {
324            ps.setLong(1, sessionId);
325
326            try (ResultSet rs = ps.executeQuery()) {
327                while (rs.next()) {
328                    sources.add(rs.getString("node_id"));
329                }
330            }
331        }
332
333        return sources;
334    }
335
336    /**
337     * セッション情報(ワークフロー名、説明)を取得する。
338     */
339    private SessionInfo querySessionInfo(long sessionId) throws Exception {
340        String sql = "SELECT workflow_name, cwd FROM sessions WHERE id = ?";
341
342        try (PreparedStatement ps = connection.prepareStatement(sql)) {
343            ps.setLong(1, sessionId);
344
345            try (ResultSet rs = ps.executeQuery()) {
346                if (rs.next()) {
347                    String workflowName = rs.getString("workflow_name");
348                    String cwd = rs.getString("cwd");
349                    String description = readWorkflowDescription(cwd, workflowName);
350                    return new SessionInfo(workflowName, description);
351                }
352            }
353        }
354
355        return new SessionInfo(null, null);
356    }
357
358    /**
359     * ワークフローYAMLからdescriptionを読み取る。
360     */
361    private String readWorkflowDescription(String cwd, String workflowName) {
362        if (cwd == null || workflowName == null) return null;
363
364        try {
365            java.nio.file.Path path = java.nio.file.Paths.get(cwd, workflowName);
366            if (!java.nio.file.Files.exists(path)) return null;
367
368            try (java.io.InputStream is = java.nio.file.Files.newInputStream(path)) {
369                org.yaml.snakeyaml.Yaml yaml = new org.yaml.snakeyaml.Yaml();
370                Map<String, Object> data = yaml.load(is);
371                if (data != null) {
372                    Object descObj = data.get("description");
373                    if (descObj != null) {
374                        String desc = descObj.toString().trim();
375                        // 一行目だけ、または60文字まで
376                        int newlineIdx = desc.indexOf('\n');
377                        if (newlineIdx > 0) {
378                            desc = desc.substring(0, newlineIdx);
379                        }
380                        if (desc.length() > 60) {
381                            desc = desc.substring(0, 57) + "...";
382                        }
383                        return desc.trim();
384                    }
385                }
386            }
387        } catch (Exception e) {
388            logger.fine("Could not read workflow description: " + e.getMessage());
389        }
390        return null;
391    }
392
393    /**
394     * セッション情報を保持する内部クラス。
395     */
396    private static class SessionInfo {
397        final String workflowName;
398        final String description;
399
400        SessionInfo(String workflowName, String description) {
401            this.workflowName = workflowName;
402            this.description = description;
403        }
404    }
405
406    private long getSessionIdFromNodeGroup() {
407        if (system == null) {
408            throw new RuntimeException("ActorSystem not set");
409        }
410
411        IIActorRef<?> nodeGroup = system.getIIActor("nodeGroup");
412        if (nodeGroup == null) {
413            throw new RuntimeException("nodeGroup not found");
414        }
415
416        ActionResult result = nodeGroup.callByActionName("getSessionId", "");
417        if (!result.isSuccess()) {
418            throw new RuntimeException("Could not get session ID from nodeGroup");
419        }
420
421        return Long.parseLong(result.getResult());
422    }
423
424    /**
425     * messageから状態遷移とnoteを抽出する。
426     * 例: "Transition SUCCESS: 0 -> 1" → ["0 -> 1", ""]
427     * 例: "Transition SUCCESS: 0 -> 1 [Collect info]" → ["0 -> 1", "Collect info"]
428     * 例: "[node-localhost] Transition SUCCESS: 1 -> 2 [Process data]" → ["1 -> 2", "Process data"]
429     *
430     * @return String[2] where [0]=transition, [1]=note
431     */
432    private String[] extractTransitionAndNote(String message) {
433        if (message == null) return new String[]{"unknown", ""};
434
435        // "Transition SUCCESS: X -> Y" または "Transition FAILED: X -> Y" のパターン
436        int idx = message.indexOf("Transition ");
437        if (idx < 0) return new String[]{"unknown", ""};
438
439        String afterTransition = message.substring(idx + "Transition ".length());
440        // "SUCCESS: 0 -> 1 [note]" or "FAILED: 0 -> 1 [note] - error message"
441        int colonIdx = afterTransition.indexOf(": ");
442        if (colonIdx < 0) return new String[]{"unknown", ""};
443
444        String statesPart = afterTransition.substring(colonIdx + 2);
445
446        // noteを抽出 [xxx]
447        String note = "";
448        int bracketStart = statesPart.indexOf(" [");
449        int bracketEnd = statesPart.indexOf("]");
450        if (bracketStart > 0 && bracketEnd > bracketStart) {
451            note = statesPart.substring(bracketStart + 2, bracketEnd);
452            statesPart = statesPart.substring(0, bracketStart);
453        }
454
455        // " - error message" があれば除去
456        int dashIdx = statesPart.indexOf(" - ");
457        if (dashIdx > 0) {
458            statesPart = statesPart.substring(0, dashIdx);
459        }
460
461        return new String[]{statesPart.trim(), note};
462    }
463
464    /**
465     * 後方互換性のためのラッパー
466     */
467    private String extractTransition(String message) {
468        return extractTransitionAndNote(message)[0];
469    }
470
471    private void outputToMultiplexer(String data) {
472        if (system == null) return;
473
474        IIActorRef<?> multiplexer = system.getIIActor("outputMultiplexer");
475        if (multiplexer == null) return;
476
477        JSONObject arg = new JSONObject();
478        arg.put("source", "transition-viewer");
479        arg.put("type", "plugin-result");
480        arg.put("data", data);
481        multiplexer.callByActionName("add", arg.toString());
482    }
483
484    /**
485     * Transition履歴エントリ。
486     */
487    private static class TransitionEntry {
488        final String timestamp;
489        final String label;
490        final String note;
491        final boolean success;
492        final String errorMessage;
493
494        TransitionEntry(String timestamp, String label, String note, boolean success, String errorMessage) {
495            this.timestamp = timestamp;
496            this.label = label;
497            this.note = note;
498            this.success = success;
499            this.errorMessage = errorMessage;
500        }
501    }
502}