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}