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; 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; 028import org.yaml.snakeyaml.Yaml; 029 030import java.io.*; 031import java.nio.file.*; 032import java.sql.*; 033import java.util.*; 034import java.util.logging.Level; 035import java.util.logging.Logger; 036 037/** 038 * Workflow execution reporter for actor-IaC. 039 * 040 * <p>Aggregates workflow execution results and generates final reports. 041 * Messages starting with '%' prefix are collected and displayed in the 042 * final report, enabling simple check/status reporting from workflows.</p> 043 * 044 * <h2>Usage in workflows:</h2> 045 * <pre> 046 * - actor: this 047 * method: executeCommand 048 * arguments: 049 * - | 050 * if command -v node > /dev/null; then 051 * echo "%[OK] Node.js: $(node --version)" 052 * else 053 * echo "%[ERROR] Node.js: not found" 054 * fi 055 * </pre> 056 * 057 * <h2>Actions:</h2> 058 * <ul> 059 * <li>{@code report} - Generate workflow execution report. Args: sessionId (optional)</li> 060 * <li>{@code transition-summary} - Show transition success/failure summary. Args: sessionId</li> 061 * </ul> 062 * 063 * <h2>Report Output Example:</h2> 064 * <pre> 065 * === Workflow Execution Report === 066 * Session #42 | Workflow: DocumentDeployWorkflow | Status: COMPLETED 067 * 068 * --- Check Results --- 069 * [OK] Node.js: v18.0.0 070 * [OK] yarn: 1.22.19 071 * [ERROR] Maven: not found 072 * 073 * --- Transitions --- 074 * [✓] check-doclist 075 * [✓] detect-changes 076 * [✗] build-docs: yarn not found 077 * 078 * === Result: FAILED === 079 * </pre> 080 * 081 * @author devteam@scivicslab.com 082 * @since 2.12.2 083 */ 084public class WorkflowReporter implements CallableByActionName, ActorSystemAware { 085 086 private static final String CLASS_NAME = WorkflowReporter.class.getName(); 087 private static final Logger logger = Logger.getLogger(CLASS_NAME); 088 089 /** Prefix for messages to be included in the final report. */ 090 private static final String REPORT_PREFIX = "%"; 091 092 private Connection connection; 093 private IIActorSystem system; 094 095 /** Pre-collected lines to be added at the beginning of the report. */ 096 private final List<String> preLines = new ArrayList<>(); 097 098 /** 099 * Sets the database connection for log queries. 100 * 101 * @param connection the JDBC connection to the H2 log database 102 */ 103 public void setConnection(Connection connection) { 104 this.connection = connection; 105 } 106 107 @Override 108 public void setActorSystem(IIActorSystem system) { 109 logger.entering(CLASS_NAME, "setActorSystem", system); 110 this.system = system; 111 112 // Auto-initialize database connection from DistributedLogStore singleton 113 if (this.connection == null) { 114 DistributedLogStore logStore = DistributedLogStore.getInstance(); 115 if (logStore != null) { 116 this.connection = logStore.getConnection(); 117 logger.info("WorkflowReporter: Auto-initialized database connection from DistributedLogStore"); 118 } else { 119 logger.warning("WorkflowReporter: DistributedLogStore singleton not available"); 120 } 121 } 122 123 logger.exiting(CLASS_NAME, "setActorSystem"); 124 } 125 126 @Override 127 public ActionResult callByActionName(String actionName, String args) { 128 logger.info("WorkflowReporter.callByActionName: actionName=" + actionName + ", args=" + args); 129 try { 130 ActionResult result = switch (actionName) { 131 case "report" -> generateReport(args); 132 case "transition-summary" -> transitionSummary(args); 133 case "addLine" -> addLine(args); 134 case "addWorkflowInfo" -> addWorkflowInfo(args); 135 default -> { 136 logger.warning("WorkflowReporter: Unknown action: " + actionName); 137 yield new ActionResult(false, "Unknown action: " + actionName); 138 } 139 }; 140 logger.info("WorkflowReporter.callByActionName: result=" + result.isSuccess()); 141 return result; 142 } catch (Exception e) { 143 ActionResult errorResult = new ActionResult(false, "Error: " + e.getMessage()); 144 logger.logp(Level.WARNING, CLASS_NAME, "callByActionName", "Exception occurred", e); 145 return errorResult; 146 } 147 } 148 149 /** 150 * Add a line to the report. 151 * 152 * <p>Lines added via this method will appear at the beginning of the 153 * "Check Results" section in the report. This is useful for adding 154 * workflow description or other contextual information.</p> 155 * 156 * <p>The argument is passed as a JSON array from the workflow engine, 157 * so this method extracts the first element.</p> 158 * 159 * @param args the JSON array containing the line to add 160 * @return ActionResult indicating success 161 */ 162 private ActionResult addLine(String args) { 163 logger.info("WorkflowReporter.addLine called with args: " + args); 164 try { 165 // Parse the JSON array to extract the actual line 166 org.json.JSONArray jsonArray = new org.json.JSONArray(args); 167 if (jsonArray.length() > 0) { 168 String line = jsonArray.getString(0); 169 preLines.add(line); 170 logger.info("WorkflowReporter.addLine: added line: " + line); 171 } 172 return new ActionResult(true, "Line added"); 173 } catch (Exception e) { 174 logger.warning("WorkflowReporter.addLine: exception: " + e.getMessage()); 175 // Fallback: treat args as plain string 176 if (args != null && !args.isEmpty() && !args.equals("[]")) { 177 preLines.add(args); 178 } 179 return new ActionResult(true, "Line added (fallback)"); 180 } 181 } 182 183 /** 184 * Add workflow metadata (file, name, description) to the report. 185 * 186 * <p>This action reads the workflow YAML file and extracts the name and 187 * description fields, adding them to the report header. The workflow 188 * file path is obtained from nodeGroup.</p> 189 * 190 * @param args unused (workflow path is obtained from nodeGroup) 191 * @return ActionResult indicating success or failure 192 */ 193 private ActionResult addWorkflowInfo(String args) { 194 logger.info("WorkflowReporter.addWorkflowInfo called"); 195 196 try { 197 // Get workflow path from nodeGroup 198 String workflowPath = getWorkflowPathFromNodeGroup(); 199 if (workflowPath == null) { 200 logger.warning("WorkflowReporter.addWorkflowInfo: could not get workflow path from nodeGroup"); 201 return new ActionResult(false, "Could not retrieve workflow path from nodeGroup"); 202 } 203 logger.info("WorkflowReporter.addWorkflowInfo: workflowPath=" + workflowPath); 204 205 // Read and parse the YAML file 206 Path path = Paths.get(workflowPath); 207 if (!Files.exists(path)) { 208 // Try relative to current working directory 209 path = Paths.get(System.getProperty("user.dir"), workflowPath); 210 } 211 if (!Files.exists(path)) { 212 // Workflow info without reading file - use only database info 213 preLines.add("[Workflow Info]"); 214 preLines.add(" File: " + workflowPath); 215 logger.info("WorkflowReporter.addWorkflowInfo: file not found, using DB info only"); 216 return new ActionResult(true, "Workflow info added (file not found, DB info only)"); 217 } 218 219 Map<String, Object> yaml; 220 try (InputStream is = Files.newInputStream(path)) { 221 Yaml yamlParser = new Yaml(); 222 yaml = yamlParser.load(is); 223 } 224 225 if (yaml == null) { 226 return new ActionResult(false, "Failed to parse workflow YAML"); 227 } 228 229 // Extract name and description 230 String name = (String) yaml.get("name"); 231 Object descObj = yaml.get("description"); 232 String description = descObj != null ? descObj.toString().trim() : null; 233 234 // Add to preLines 235 preLines.add("[Workflow Info]"); 236 preLines.add(" File: " + workflowPath); 237 if (name != null) { 238 preLines.add(" Name: " + name); 239 } 240 if (description != null) { 241 preLines.add(""); 242 preLines.add("[Description]"); 243 // Indent each line of description 244 for (String line : description.split("\n")) { 245 preLines.add(" " + line.trim()); 246 } 247 } 248 249 logger.info("WorkflowReporter.addWorkflowInfo: added workflow info for " + workflowPath); 250 return new ActionResult(true, "Workflow info added"); 251 252 } catch (Exception e) { 253 logger.warning("WorkflowReporter.addWorkflowInfo: exception: " + e.getMessage()); 254 return new ActionResult(false, "Failed to add workflow info: " + e.getMessage()); 255 } 256 } 257 258 /** 259 * Retrieves workflow file path from session in database. 260 */ 261 private String getWorkflowPathFromSession(long sessionId) throws SQLException { 262 String sql = "SELECT workflow_name FROM sessions WHERE id = ?"; 263 try (PreparedStatement ps = connection.prepareStatement(sql)) { 264 ps.setLong(1, sessionId); 265 try (ResultSet rs = ps.executeQuery()) { 266 if (rs.next()) { 267 return rs.getString("workflow_name"); 268 } 269 } 270 } 271 return null; 272 } 273 274 /** 275 * Retrieves workflow file path from nodeGroup actor. 276 */ 277 private String getWorkflowPathFromNodeGroup() { 278 if (system == null) { 279 return null; 280 } 281 IIActorRef<?> nodeGroup = system.getIIActor("nodeGroup"); 282 if (nodeGroup == null) { 283 return null; 284 } 285 ActionResult result = nodeGroup.callByActionName("getWorkflowPath", ""); 286 return result.isSuccess() ? result.getResult() : null; 287 } 288 289 /** 290 * Generate workflow execution report. 291 * 292 * <p>Collects messages starting with '%' prefix and generates a summary report.</p> 293 * 294 * @param sessionIdStr session ID to report on, or empty for auto-retrieval 295 */ 296 private ActionResult generateReport(String sessionIdStr) { 297 logger.entering(CLASS_NAME, "generateReport", sessionIdStr); 298 if (connection == null) { 299 return new ActionResult(false, "Not connected. Database connection not set."); 300 } 301 302 try { 303 long sessionId = resolveSessionId(sessionIdStr); 304 305 StringBuilder sb = new StringBuilder(); 306 sb.append("=== Workflow Execution Report ===\n"); 307 308 // Get session info 309 String sessionInfo = getSessionInfo(sessionId); 310 if (sessionInfo != null) { 311 sb.append(sessionInfo).append("\n"); 312 } 313 314 // Add pre-collected lines first 315 if (!preLines.isEmpty()) { 316 sb.append("\n"); 317 for (String line : preLines) { 318 sb.append(line).append("\n"); 319 } 320 } 321 322 // Get messages with % prefix 323 List<String> reportMessages = getReportMessages(sessionId); 324 if (!reportMessages.isEmpty()) { 325 sb.append("\n--- Check Results ---\n"); 326 for (String msg : reportMessages) { 327 sb.append(msg).append("\n"); 328 } 329 } 330 331 // Get transition summary 332 String transitionInfo = buildTransitionSummary(sessionId); 333 if (transitionInfo != null) { 334 sb.append("\n--- Transitions ---\n"); 335 sb.append(transitionInfo); 336 } 337 338 String result = sb.toString(); 339 reportToMultiplexer(result); 340 return new ActionResult(true, result); 341 342 } catch (Exception e) { 343 return new ActionResult(false, "Report generation failed: " + e.getMessage()); 344 } 345 } 346 347 /** 348 * Get session information. 349 */ 350 private String getSessionInfo(long sessionId) throws SQLException { 351 String sql = "SELECT workflow_name, overlay_name, status, started_at, ended_at " + 352 "FROM sessions WHERE id = ?"; 353 try (PreparedStatement ps = connection.prepareStatement(sql)) { 354 ps.setLong(1, sessionId); 355 try (ResultSet rs = ps.executeQuery()) { 356 if (rs.next()) { 357 String workflow = rs.getString("workflow_name"); 358 String overlay = rs.getString("overlay_name"); 359 String status = rs.getString("status"); 360 Timestamp started = rs.getTimestamp("started_at"); 361 Timestamp ended = rs.getTimestamp("ended_at"); 362 363 StringBuilder sb = new StringBuilder(); 364 sb.append("Session #").append(sessionId); 365 if (workflow != null) sb.append(" | Workflow: ").append(workflow); 366 if (overlay != null) sb.append(" | Overlay: ").append(overlay); 367 if (started != null) sb.append("\nStarted: ").append(started); 368 if (ended != null) sb.append(" | Ended: ").append(ended); 369 return sb.toString(); 370 } 371 } 372 } 373 return null; 374 } 375 376 /** 377 * Get messages with % prefix from logs. 378 */ 379 private List<String> getReportMessages(long sessionId) throws SQLException { 380 List<String> messages = new ArrayList<>(); 381 382 String sql = "SELECT message FROM logs WHERE session_id = ? ORDER BY timestamp"; 383 try (PreparedStatement ps = connection.prepareStatement(sql)) { 384 ps.setLong(1, sessionId); 385 try (ResultSet rs = ps.executeQuery()) { 386 while (rs.next()) { 387 String message = rs.getString("message"); 388 if (message != null) { 389 // Extract lines starting with % 390 for (String line : message.split("\n")) { 391 String trimmed = line.trim(); 392 // Handle prefixes like [node-xxx] %message 393 String cleaned = trimmed.replaceFirst("^\\[node-[^\\]]+\\]\\s*", ""); 394 if (cleaned.startsWith(REPORT_PREFIX)) { 395 messages.add(cleaned.substring(1).trim()); 396 } 397 } 398 } 399 } 400 } 401 } 402 // Remove duplicates while preserving order, then sort by hostname 403 List<String> uniqueMessages = new ArrayList<>(new java.util.LinkedHashSet<>(messages)); 404 uniqueMessages.sort(null); 405 return uniqueMessages; 406 } 407 408 /** 409 * Build transition summary. 410 */ 411 private String buildTransitionSummary(long sessionId) throws SQLException { 412 String sql = "SELECT label, level, message FROM logs " + 413 "WHERE session_id = ? AND message LIKE 'Transition %' " + 414 "ORDER BY timestamp"; 415 416 StringBuilder sb = new StringBuilder(); 417 int success = 0; 418 int failed = 0; 419 420 try (PreparedStatement ps = connection.prepareStatement(sql)) { 421 ps.setLong(1, sessionId); 422 try (ResultSet rs = ps.executeQuery()) { 423 while (rs.next()) { 424 String label = rs.getString("label"); 425 String level = rs.getString("level"); 426 String message = rs.getString("message"); 427 428 boolean isSuccess = message.contains("SUCCESS"); 429 if (isSuccess) { 430 success++; 431 sb.append("[✓] ").append(label != null ? label : "unknown").append("\n"); 432 } else { 433 failed++; 434 sb.append("[✗] ").append(label != null ? label : "unknown"); 435 // Extract failure reason 436 int dashIdx = message.indexOf(" - "); 437 if (dashIdx > 0) { 438 sb.append(": ").append(message.substring(dashIdx + 3)); 439 } 440 sb.append("\n"); 441 } 442 } 443 } 444 } 445 446 if (success == 0 && failed == 0) { 447 return null; 448 } 449 450 sb.append("\nSummary: ").append(success).append(" succeeded, ").append(failed).append(" failed"); 451 return sb.toString(); 452 } 453 454 /** 455 * Get final session status. 456 */ 457 private String getFinalStatus(long sessionId) throws SQLException { 458 String sql = "SELECT status FROM sessions WHERE id = ?"; 459 try (PreparedStatement ps = connection.prepareStatement(sql)) { 460 ps.setLong(1, sessionId); 461 try (ResultSet rs = ps.executeQuery()) { 462 if (rs.next()) { 463 String status = rs.getString("status"); 464 if ("COMPLETED".equals(status)) { 465 return "=== Result: SUCCESS ==="; 466 } else if ("FAILED".equals(status)) { 467 return "=== Result: FAILED ==="; 468 } else { 469 return "=== Result: " + status + " ==="; 470 } 471 } 472 } 473 } 474 return "=== Result: UNKNOWN ==="; 475 } 476 477 /** 478 * Show transition success/failure summary. 479 */ 480 private ActionResult transitionSummary(String sessionIdStr) { 481 logger.entering(CLASS_NAME, "transitionSummary", sessionIdStr); 482 if (connection == null) { 483 return new ActionResult(false, "Not connected. Database connection not set."); 484 } 485 486 try { 487 long sessionId = resolveSessionId(sessionIdStr); 488 String summary = buildTransitionSummary(sessionId); 489 if (summary == null) { 490 summary = "No transition data found for session " + sessionId; 491 } 492 reportToMultiplexer(summary); 493 return new ActionResult(true, summary); 494 } catch (Exception e) { 495 return new ActionResult(false, "Query failed: " + e.getMessage()); 496 } 497 } 498 499 /** 500 * Resolve session ID from argument or auto-retrieve from nodeGroup. 501 */ 502 private long resolveSessionId(String sessionIdStr) throws NumberFormatException { 503 if (sessionIdStr == null || sessionIdStr.trim().isEmpty() || sessionIdStr.equals("[]")) { 504 String autoSessionId = getSessionIdFromNodeGroup(); 505 if (autoSessionId == null) { 506 throw new NumberFormatException("Session ID not specified and could not retrieve from nodeGroup"); 507 } 508 return Long.parseLong(autoSessionId); 509 } 510 return Long.parseLong(sessionIdStr.trim()); 511 } 512 513 /** 514 * Retrieves session ID from nodeGroup actor. 515 */ 516 private String getSessionIdFromNodeGroup() { 517 if (system == null) { 518 return null; 519 } 520 IIActorRef<?> nodeGroup = system.getIIActor("nodeGroup"); 521 if (nodeGroup == null) { 522 return null; 523 } 524 ActionResult result = nodeGroup.callByActionName("getSessionId", ""); 525 return result.isSuccess() ? result.getResult() : null; 526 } 527 528 /** 529 * Report result to outputMultiplexer. 530 */ 531 private void reportToMultiplexer(String data) { 532 if (system == null) { 533 return; 534 } 535 536 IIActorRef<?> multiplexer = system.getIIActor("outputMultiplexer"); 537 if (multiplexer == null) { 538 return; 539 } 540 541 JSONObject arg = new JSONObject(); 542 arg.put("source", "workflow-reporter"); 543 arg.put("type", "plugin-result"); 544 arg.put("data", data); 545 multiplexer.callByActionName("add", arg.toString()); 546 } 547}