001/*
002 * Copyright 2025 devteam@scivics-lab.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.cli;
019
020import java.io.File;
021import java.io.FileInputStream;
022import java.io.IOException;
023import java.nio.file.Files;
024import java.nio.file.Path;
025import java.sql.SQLException;
026import java.time.LocalDateTime;
027import java.time.format.DateTimeFormatter;
028import java.util.HashMap;
029import java.util.LinkedHashMap;
030import java.util.List;
031import java.util.Map;
032import java.util.concurrent.Callable;
033import java.util.logging.FileHandler;
034import java.util.logging.Level;
035import java.util.logging.Logger;
036import java.util.logging.SimpleFormatter;
037import java.util.stream.Collectors;
038import java.util.stream.Stream;
039import java.util.Comparator;
040
041import com.scivicslab.actoriac.NodeGroup;
042import com.scivicslab.actoriac.NodeGroupInterpreter;
043import com.scivicslab.actoriac.NodeGroupIIAR;
044import com.scivicslab.actoriac.log.DistributedLogStore;
045import com.scivicslab.actoriac.log.H2LogStore;
046import com.scivicslab.actoriac.log.LogLevel;
047import com.scivicslab.actoriac.log.SessionStatus;
048import com.scivicslab.pojoactor.core.ActionResult;
049import com.scivicslab.pojoactor.workflow.IIActorSystem;
050import com.scivicslab.pojoactor.workflow.kustomize.WorkflowKustomizer;
051
052import picocli.CommandLine;
053import picocli.CommandLine.Command;
054import picocli.CommandLine.Option;
055
056/**
057 * CLI subcommand to execute actor-IaC workflows.
058 *
059 * <p>This is the main workflow execution command. It supports executing workflows
060 * defined in YAML, JSON, or XML format with optional overlay configuration.</p>
061 *
062 * <h2>Usage</h2>
063 * <pre>
064 * actor-iac run -d /path/to/workflows -w main-workflow.yaml
065 * actor-iac run --dir ./workflows --workflow deploy --threads 8
066 * actor-iac run -d ./workflows -w deploy -i inventory.ini -g webservers
067 * </pre>
068 *
069 * @author devteam@scivics-lab.com
070 * @since 2.10.0
071 */
072@Command(
073    name = "run",
074    mixinStandardHelpOptions = true,
075    version = "actor-IaC run 2.11.0",
076    description = "Execute actor-IaC workflows defined in YAML, JSON, or XML format."
077)
078public class RunCLI implements Callable<Integer> {
079
080    private static final Logger LOG = Logger.getLogger(RunCLI.class.getName());
081
082    @Option(
083        names = {"-d", "--dir"},
084        required = true,
085        description = "Directory containing workflow files (searched recursively)"
086    )
087    private File workflowDir;
088
089    @Option(
090        names = {"-w", "--workflow"},
091        description = "Name of the main workflow to execute (with or without extension)"
092    )
093    private String workflowName;
094
095    @Option(
096        names = {"-i", "--inventory"},
097        description = "Path to Ansible inventory file (enables node-based execution)"
098    )
099    private File inventoryFile;
100
101    @Option(
102        names = {"-g", "--group"},
103        description = "Name of the host group to target (requires --inventory)",
104        defaultValue = "all"
105    )
106    private String groupName;
107
108    @Option(
109        names = {"-t", "--threads"},
110        description = "Number of worker threads for CPU-bound operations (default: ${DEFAULT-VALUE})",
111        defaultValue = "4"
112    )
113    private int threads;
114
115    @Option(
116        names = {"-v", "--verbose"},
117        description = "Enable verbose output"
118    )
119    private boolean verbose;
120
121    @Option(
122        names = {"-o", "--overlay"},
123        description = "Overlay directory containing overlay-conf.yaml for environment-specific configuration"
124    )
125    private File overlayDir;
126
127    @Option(
128        names = {"-l", "--log"},
129        description = "Log file path (default: actor-iac-YYYYMMDDHHmm.log in current directory)"
130    )
131    private File logFile;
132
133    @Option(
134        names = {"--no-log"},
135        description = "Disable file logging (output to console only)"
136    )
137    private boolean noLog;
138
139    @Option(
140        names = {"--log-db"},
141        description = "H2 database path for distributed logging (default: actor-iac-logs in workflow directory)"
142    )
143    private File logDbPath;
144
145    @Option(
146        names = {"--no-log-db"},
147        description = "Disable H2 database logging"
148    )
149    private boolean noLogDb;
150
151    @Option(
152        names = {"--log-serve"},
153        description = "H2 log server address (host:port, e.g., localhost:29090). " +
154                     "Enables multiple workflows to share a single log database. " +
155                     "Falls back to embedded mode if server is unreachable."
156    )
157    private String logServer;
158
159    @Option(
160        names = {"--embedded"},
161        description = "Use embedded H2 database mode. " +
162                     "Default behavior is to auto-start a TCP server with auto-shutdown."
163    )
164    private boolean embedded;
165
166    @Option(
167        names = {"-k", "--ask-pass"},
168        description = "Prompt for SSH password (uses password authentication instead of ssh-agent)"
169    )
170    private boolean askPass;
171
172    @Option(
173        names = {"--limit"},
174        description = "Limit execution to specific hosts (comma-separated, e.g., '192.168.5.15' or '192.168.5.15,192.168.5.16')"
175    )
176    private String limitHosts;
177
178    @Option(
179        names = {"-L", "--list-workflows"},
180        description = "List workflows discovered under --dir and exit"
181    )
182    private boolean listWorkflows;
183
184    @Option(
185        names = {"-q", "--quiet"},
186        description = "Suppress all console output (stdout/stderr). Logs are still written to database."
187    )
188    private boolean quiet;
189
190    @Option(
191        names = {"--render-to"},
192        description = "Render overlay-applied workflows to specified directory (does not execute)"
193    )
194    private File renderToDir;
195
196    /** Cache of discovered workflow files: name -> File */
197    private final Map<String, File> workflowCache = new HashMap<>();
198
199    /** File handler for logging */
200    private FileHandler fileHandler;
201
202    /** Distributed log store (H2 database) */
203    private DistributedLogStore logStore;
204
205    /** Current session ID for distributed logging */
206    private long sessionId = -1;
207
208    /**
209     * Executes the workflow.
210     *
211     * @return exit code (0 for success, non-zero for failure)
212     */
213    @Override
214    public Integer call() {
215        // Validate required options
216        // --render-to only requires --dir and --overlay, not --workflow
217        if (renderToDir != null) {
218            if (overlayDir == null) {
219                if (!quiet) {
220                    System.err.println("--render-to requires '--overlay=<overlayDir>'");
221                    CommandLine.usage(this, System.err);
222                }
223                return 2;
224            }
225        } else if (workflowName == null && !listWorkflows) {
226            if (!quiet) {
227                System.err.println("Missing required option: '--workflow=<workflowName>'");
228                System.err.println("Use --list-workflows to see available workflows.");
229                CommandLine.usage(this, System.err);
230            }
231            return 2;
232        }
233
234        // In quiet mode, suppress all console output
235        if (quiet) {
236            suppressConsoleOutput();
237        }
238
239        // Setup file logging
240        if (!noLog) {
241            try {
242                setupFileLogging();
243            } catch (IOException e) {
244                if (!quiet) {
245                    System.err.println("Warning: Failed to setup file logging: " + e.getMessage());
246                }
247            }
248        }
249
250        // Configure log level based on verbose flag
251        configureLogLevel(verbose);
252
253        // Setup H2 log database (enabled by default, use --no-log-db to disable)
254        if (!noLogDb) {
255            if (logDbPath == null) {
256                // Default: actor-iac-logs in workflow directory
257                logDbPath = new File(workflowDir, "actor-iac-logs");
258            }
259            try {
260                setupLogDatabase();
261            } catch (SQLException e) {
262                if (!quiet) {
263                    System.err.println("Warning: Failed to setup log database: " + e.getMessage());
264                }
265            }
266        }
267
268        try {
269            return executeMain();
270        } finally {
271            // Clean up resources
272            if (fileHandler != null) {
273                fileHandler.close();
274            }
275            if (logStore != null) {
276                try {
277                    logStore.close();
278                } catch (Exception e) {
279                    System.err.println("Warning: Failed to close log database: " + e.getMessage());
280                }
281            }
282        }
283    }
284
285    /**
286     * Suppresses all console output (stdout/stderr) for quiet mode.
287     */
288    private void suppressConsoleOutput() {
289        // Remove ConsoleHandler from root logger
290        Logger rootLogger = Logger.getLogger("");
291        for (java.util.logging.Handler handler : rootLogger.getHandlers()) {
292            if (handler instanceof java.util.logging.ConsoleHandler) {
293                rootLogger.removeHandler(handler);
294            }
295        }
296
297        // Redirect System.out and System.err to null
298        java.io.PrintStream nullStream = new java.io.PrintStream(java.io.OutputStream.nullOutputStream());
299        System.setOut(nullStream);
300        System.setErr(nullStream);
301    }
302
303    /**
304     * Sets up H2 database for distributed logging.
305     *
306     * <p>Connection priority:</p>
307     * <ol>
308     *   <li>If --embedded is specified, use embedded mode</li>
309     *   <li>If --log-serve is specified, connect to that server</li>
310     *   <li>Otherwise (default): auto-detect or start a background TCP server with auto-shutdown</li>
311     * </ol>
312     */
313    private void setupLogDatabase() throws SQLException {
314        Path dbPath = logDbPath.toPath();
315
316        // Priority 1: Explicit embedded mode
317        if (embedded) {
318            logStore = new H2LogStore(dbPath);
319            System.out.println("Log database: " + logDbPath.getAbsolutePath() + ".mv.db (embedded mode)");
320            return;
321        }
322
323        // Priority 2: Explicit log server specified
324        if (logServer != null && !logServer.isBlank()) {
325            logStore = H2LogStore.createWithFallback(logServer, dbPath);
326            System.out.println("Log database: " + logServer + " (TCP mode)");
327            return;
328        }
329
330        // Priority 3 (default): Auto-detect existing server or start a new one
331        LogServerDiscovery discovery = new LogServerDiscovery();
332        LogServerDiscovery.DiscoveryResult result = discovery.discoverServer(dbPath);
333
334        if (result.isFound()) {
335            // Found existing server, connect to it
336            logStore = H2LogStore.createWithFallback(result.getServerAddress(), dbPath);
337            System.out.println("Auto-detected log server at " + result.getServerAddress());
338            System.out.println("Log database: " + result.getServerAddress() + " (TCP mode)");
339            return;
340        }
341
342        // No existing server found - start a background server with auto-shutdown
343        int serverPort = startBackgroundLogServer(dbPath);
344        if (serverPort > 0) {
345            String serverAddress = "localhost:" + serverPort;
346            logStore = H2LogStore.createWithFallback(serverAddress, dbPath);
347            System.out.println("Started background log server on port " + serverPort);
348            System.out.println("Log database: " + serverAddress + " (TCP mode, auto-shutdown enabled)");
349        } else {
350            // Failed to start server, fall back to embedded mode
351            System.out.println("Warning: Failed to start background log server, using embedded mode");
352            logStore = new H2LogStore(dbPath);
353            System.out.println("Log database: " + logDbPath.getAbsolutePath() + ".mv.db (embedded mode)");
354        }
355    }
356
357    /** Background log server process (for auto-shutdown) */
358    private Process backgroundServerProcess;
359
360    /** Background log server port */
361    private int backgroundServerPort = -1;
362
363    /**
364     * Starts a background H2 log server with auto-shutdown.
365     *
366     * <p>The server runs in a separate process and will automatically shut down
367     * when there are no connections for 5 minutes.</p>
368     *
369     * @param dbPath database path
370     * @return the TCP port of the started server, or -1 if failed
371     */
372    private int startBackgroundLogServer(Path dbPath) {
373        // Find an available port in the reserved range
374        int port = findAvailablePort();
375        if (port < 0) {
376            LOG.warning("No available ports in range 29090-29100");
377            return -1;
378        }
379
380        try {
381            // Get the path to actor_iac.java
382            String actorIacPath = findActorIacScript();
383            if (actorIacPath == null) {
384                LOG.warning("Could not find actor_iac.java script");
385                return -1;
386            }
387
388            // Build the command to start log server with auto-shutdown
389            ProcessBuilder pb = new ProcessBuilder(
390                actorIacPath,
391                "log-serve",
392                "--port", String.valueOf(port),
393                "--db", dbPath.toAbsolutePath().toString(),
394                "--auto-shutdown",
395                "--idle-timeout", "300",  // 5 minutes
396                "--check-interval", "60"   // Check every 1 minute
397            );
398
399            // Redirect output to null (server runs silently in background)
400            pb.redirectOutput(ProcessBuilder.Redirect.DISCARD);
401            pb.redirectError(ProcessBuilder.Redirect.DISCARD);
402
403            // Start the server process
404            backgroundServerProcess = pb.start();
405            backgroundServerPort = port;
406
407            // Wait briefly for server to start
408            Thread.sleep(1000);
409
410            // Verify server is running
411            if (!backgroundServerProcess.isAlive()) {
412                LOG.warning("Background log server process exited unexpectedly");
413                return -1;
414            }
415
416            // Verify we can connect
417            LogServerDiscovery.DiscoveryResult verify = new LogServerDiscovery().discoverServer(dbPath);
418            if (!verify.isFound()) {
419                LOG.warning("Background log server started but not responding");
420                backgroundServerProcess.destroy();
421                return -1;
422            }
423
424            if (verbose) {
425                LOG.info("Background log server started on port " + port + " (PID: " + backgroundServerProcess.pid() + ")");
426            }
427
428            return port;
429
430        } catch (Exception e) {
431            LOG.warning("Failed to start background log server: " + e.getMessage());
432            return -1;
433        }
434    }
435
436    /**
437     * Finds an available port in the actor-IaC reserved range (29090-29100).
438     */
439    private int findAvailablePort() {
440        int[] ports = {29090, 29091, 29092, 29093, 29094, 29095, 29096, 29097, 29098, 29099, 29100};
441        for (int port : ports) {
442            try (java.net.Socket socket = new java.net.Socket("localhost", port)) {
443                // Port is in use
444            } catch (Exception e) {
445                // Port is available
446                return port;
447            }
448        }
449        return -1;
450    }
451
452    /**
453     * Finds the path to the actor_iac.java script.
454     */
455    private String findActorIacScript() {
456        // Try common locations
457        String[] candidates = {
458            "./actor_iac.java",
459            "../actor_iac.java",
460            System.getProperty("user.dir") + "/actor_iac.java",
461            workflowDir.getParent() + "/actor_iac.java"
462        };
463
464        for (String path : candidates) {
465            java.io.File file = new java.io.File(path);
466            if (file.exists() && file.canExecute()) {
467                return file.getAbsolutePath();
468            }
469        }
470
471        // Try to find via PATH
472        try {
473            ProcessBuilder pb = new ProcessBuilder("which", "actor_iac.java");
474            Process p = pb.start();
475            try (java.io.BufferedReader reader = new java.io.BufferedReader(
476                    new java.io.InputStreamReader(p.getInputStream()))) {
477                String line = reader.readLine();
478                if (line != null && !line.isBlank()) {
479                    return line.trim();
480                }
481            }
482        } catch (Exception e) {
483            // Ignore
484        }
485
486        return null;
487    }
488
489    /**
490     * Sets up file logging with default or specified log file.
491     */
492    private void setupFileLogging() throws IOException {
493        String logFilePath;
494        if (logFile != null) {
495            logFilePath = logFile.getAbsolutePath();
496        } else {
497            // Generate default log file name: actor-iac-YYYYMMDDHHmm.log
498            String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmm"));
499            logFilePath = "actor-iac-" + timestamp + ".log";
500        }
501
502        fileHandler = new FileHandler(logFilePath, false);
503        fileHandler.setFormatter(new SimpleFormatter());
504
505        // Add handler to root logger to capture all logs
506        Logger rootLogger = Logger.getLogger("");
507        rootLogger.addHandler(fileHandler);
508
509        System.out.println("Logging to: " + logFilePath);
510    }
511
512    /**
513     * Configures log level based on verbose flag.
514     */
515    private void configureLogLevel(boolean verbose) {
516        Level targetLevel = verbose ? Level.FINE : Level.INFO;
517
518        // Configure root logger level
519        Logger rootLogger = Logger.getLogger("");
520        rootLogger.setLevel(targetLevel);
521
522        // Configure handlers to respect the new level
523        for (java.util.logging.Handler handler : rootLogger.getHandlers()) {
524            handler.setLevel(targetLevel);
525        }
526
527        // Configure POJO-actor Interpreter logger
528        Logger interpreterLogger = Logger.getLogger("com.scivicslab.pojoactor.workflow.Interpreter");
529        interpreterLogger.setLevel(targetLevel);
530
531        if (verbose) {
532            LOG.info("Verbose mode enabled - log level set to FINE");
533        }
534    }
535
536    /**
537     * Main execution logic.
538     */
539    private Integer executeMain() {
540        // Validate workflow directory
541        if (!workflowDir.exists()) {
542            LOG.severe("Workflow directory does not exist: " + workflowDir);
543            return 1;
544        }
545
546        if (!workflowDir.isDirectory()) {
547            LOG.severe("Not a directory: " + workflowDir);
548            return 1;
549        }
550
551        // Scan workflow directory recursively
552        LOG.info("Scanning workflow directory: " + workflowDir.getAbsolutePath());
553        scanWorkflowDirectory(workflowDir.toPath());
554
555        if (verbose) {
556            LOG.info("Discovered " + workflowCache.size() + " workflow files:");
557            workflowCache.forEach((name, file) ->
558                LOG.info("  " + name + " -> " + file.getPath()));
559        }
560
561        if (listWorkflows) {
562            printWorkflowList();
563            return 0;
564        }
565
566        // Handle --render-to option
567        if (renderToDir != null) {
568            return renderOverlayWorkflows();
569        }
570
571        if (workflowName == null || workflowName.isBlank()) {
572            LOG.severe("Workflow name is required unless --list-workflows is specified.");
573            return 1;
574        }
575
576        // Find main workflow
577        File mainWorkflowFile = findWorkflowFile(workflowName);
578        if (mainWorkflowFile == null) {
579            LOG.severe("Workflow not found: " + workflowName);
580            LOG.info("Available workflows: " + workflowCache.keySet());
581            return 1;
582        }
583
584        // Validate overlay directory if specified
585        if (overlayDir != null) {
586            if (!overlayDir.exists()) {
587                LOG.severe("Overlay directory does not exist: " + overlayDir);
588                return 1;
589            }
590            if (!overlayDir.isDirectory()) {
591                LOG.severe("Overlay path is not a directory: " + overlayDir);
592                return 1;
593            }
594        }
595
596        LOG.info("=== actor-IaC Workflow CLI ===");
597        LOG.info("Workflow directory: " + workflowDir.getAbsolutePath());
598        LOG.info("Main workflow: " + mainWorkflowFile.getName());
599        if (overlayDir != null) {
600            LOG.info("Overlay: " + overlayDir.getAbsolutePath());
601        }
602        LOG.info("Worker threads: " + threads);
603
604        // Execute workflow (use local inventory if none specified)
605        return executeWorkflow(mainWorkflowFile);
606    }
607
608    /**
609     * Renders overlay-applied workflows to the specified directory.
610     */
611    private Integer renderOverlayWorkflows() {
612        if (overlayDir == null) {
613            LOG.severe("--render-to requires --overlay to be specified");
614            return 1;
615        }
616
617        try {
618            // Create output directory if it doesn't exist
619            if (!renderToDir.exists()) {
620                if (!renderToDir.mkdirs()) {
621                    LOG.severe("Failed to create output directory: " + renderToDir);
622                    return 1;
623                }
624            }
625
626            LOG.info("=== Rendering Overlay-Applied Workflows ===");
627            LOG.info("Overlay directory: " + overlayDir.getAbsolutePath());
628            LOG.info("Output directory: " + renderToDir.getAbsolutePath());
629
630            WorkflowKustomizer kustomizer = new WorkflowKustomizer();
631            Map<String, Map<String, Object>> workflows = kustomizer.build(overlayDir.toPath());
632
633            org.yaml.snakeyaml.Yaml yaml = new org.yaml.snakeyaml.Yaml();
634
635            for (Map.Entry<String, Map<String, Object>> entry : workflows.entrySet()) {
636                String fileName = entry.getKey();
637                Map<String, Object> workflowContent = entry.getValue();
638
639                Path outputPath = renderToDir.toPath().resolve(fileName);
640
641                // Add header comment
642                StringBuilder sb = new StringBuilder();
643                sb.append("# Rendered from overlay: ").append(overlayDir.getName()).append("\n");
644                sb.append("# Source: ").append(fileName).append("\n");
645                sb.append("# Generated: ").append(LocalDateTime.now()).append("\n");
646                sb.append("#\n");
647                sb.append(yaml.dump(workflowContent));
648
649                Files.writeString(outputPath, sb.toString());
650                LOG.info("  Written: " + outputPath);
651            }
652
653            LOG.info("Rendered " + workflows.size() + " workflow(s) to " + renderToDir.getAbsolutePath());
654            return 0;
655
656        } catch (IOException e) {
657            LOG.severe("Failed to render workflows: " + e.getMessage());
658            return 1;
659        } catch (Exception e) {
660            LOG.severe("Error during rendering: " + e.getMessage());
661            e.printStackTrace();
662            return 1;
663        }
664    }
665
666    /**
667     * Executes workflow with node-based execution.
668     */
669    private Integer executeWorkflow(File mainWorkflowFile) {
670        NodeGroup nodeGroup;
671
672        // Prompt for password if --ask-pass is specified
673        String sshPassword = null;
674        if (askPass) {
675            sshPassword = promptForPassword("SSH password: ");
676            if (sshPassword == null) {
677                LOG.severe("Password input cancelled");
678                return 1;
679            }
680        }
681
682        IIActorSystem system = new IIActorSystem("actor-iac-cli", threads);
683
684        // Start log session if log database is configured
685        if (logStore != null) {
686            String overlayName = overlayDir != null ? overlayDir.getName() : null;
687            String inventoryName = inventoryFile != null ? inventoryFile.getName() : null;
688            sessionId = logStore.startSession(workflowName, overlayName, inventoryName, 1);
689            logStore.log(sessionId, "cli", LogLevel.INFO, "Starting workflow: " + workflowName);
690        }
691
692        try {
693            if (inventoryFile != null) {
694                // Use specified inventory file
695                if (!inventoryFile.exists()) {
696                    LOG.severe("Inventory file does not exist: " + inventoryFile);
697                    logToDb("cli", LogLevel.ERROR, "Inventory file does not exist: " + inventoryFile);
698                    return 1;
699                }
700                LOG.info("Inventory: " + inventoryFile.getAbsolutePath());
701                nodeGroup = new NodeGroup.Builder()
702                    .withInventory(new FileInputStream(inventoryFile))
703                    .build();
704            } else {
705                // Use empty NodeGroup for local execution
706                LOG.info("Inventory: (none - using localhost)");
707                nodeGroup = new NodeGroup();
708            }
709
710            // Set SSH password if provided
711            if (sshPassword != null) {
712                nodeGroup.setSshPassword(sshPassword);
713                LOG.info("Authentication: password");
714            } else {
715                LOG.info("Authentication: ssh-agent");
716            }
717
718            // Set host limit if provided
719            if (limitHosts != null) {
720                nodeGroup.setHostLimit(limitHosts);
721                LOG.info("Host limit: " + limitHosts);
722            }
723
724            // Step 1: Create NodeGroupInterpreter
725            NodeGroupInterpreter nodeGroupInterpreter = new NodeGroupInterpreter(nodeGroup, system);
726            nodeGroupInterpreter.setWorkflowBaseDir(workflowDir.getAbsolutePath());
727            if (overlayDir != null) {
728                nodeGroupInterpreter.setOverlayDir(overlayDir.getAbsolutePath());
729            }
730            if (verbose) {
731                nodeGroupInterpreter.setVerbose(true);
732            }
733
734            // Inject log store into interpreter for node-level logging
735            if (logStore != null) {
736                nodeGroupInterpreter.setLogStore(logStore, sessionId);
737            }
738
739            // Step 2: Create NodeGroupIIAR and register with system
740            NodeGroupIIAR nodeGroupActor = new NodeGroupIIAR("nodeGroup", nodeGroupInterpreter, system);
741            system.addIIActor(nodeGroupActor);
742
743            // Step 3: Load the main workflow (with overlay if specified)
744            ActionResult loadResult = loadMainWorkflow(nodeGroupActor, mainWorkflowFile, overlayDir);
745            if (!loadResult.isSuccess()) {
746                LOG.severe("Failed to load workflow: " + loadResult.getResult());
747                logToDb("cli", LogLevel.ERROR, "Failed to load workflow: " + loadResult.getResult());
748                endSession(SessionStatus.FAILED);
749                return 1;
750            }
751
752            // Step 4: Execute the workflow
753            LOG.info("Starting workflow execution...");
754            LOG.info("-".repeat(50));
755            logToDb("cli", LogLevel.INFO, "Starting workflow execution");
756
757            long startTime = System.currentTimeMillis();
758            ActionResult result = nodeGroupActor.callByActionName("runUntilEnd", "[10000]");
759            long duration = System.currentTimeMillis() - startTime;
760
761            LOG.info("-".repeat(50));
762            if (result.isSuccess()) {
763                LOG.info("Workflow completed successfully: " + result.getResult());
764                logToDb("cli", LogLevel.INFO, "Workflow completed successfully in " + duration + "ms");
765                endSession(SessionStatus.COMPLETED);
766                return 0;
767            } else {
768                LOG.severe("Workflow failed: " + result.getResult());
769                logToDb("cli", LogLevel.ERROR, "Workflow failed: " + result.getResult());
770                endSession(SessionStatus.FAILED);
771                return 1;
772            }
773
774        } catch (Exception e) {
775            LOG.log(Level.SEVERE, "Workflow execution failed", e);
776            logToDb("cli", LogLevel.ERROR, "Exception: " + e.getMessage());
777            endSession(SessionStatus.FAILED);
778            return 1;
779        } finally {
780            system.terminate();
781        }
782    }
783
784    /**
785     * Logs a message to the distributed log store if available.
786     */
787    private void logToDb(String nodeId, LogLevel level, String message) {
788        if (logStore != null && sessionId >= 0) {
789            logStore.log(sessionId, nodeId, level, message);
790        }
791    }
792
793    /**
794     * Ends the current session with the given status.
795     */
796    private void endSession(SessionStatus status) {
797        if (logStore != null && sessionId >= 0) {
798            logStore.endSession(sessionId, status);
799        }
800    }
801
802    /**
803     * Prompts for a password from the console.
804     */
805    private String promptForPassword(String prompt) {
806        java.io.Console console = System.console();
807        if (console != null) {
808            // Secure input - password not echoed
809            char[] passwordChars = console.readPassword(prompt);
810            if (passwordChars != null) {
811                return new String(passwordChars);
812            }
813            return null;
814        } else {
815            // Fallback for environments without console (e.g., IDE)
816            System.out.print(prompt);
817            try (java.util.Scanner scanner = new java.util.Scanner(System.in)) {
818                if (scanner.hasNextLine()) {
819                    return scanner.nextLine();
820                }
821            }
822            return null;
823        }
824    }
825
826    /**
827     * Scans the directory recursively for workflow files.
828     */
829    private void scanWorkflowDirectory(Path dir) {
830        try (Stream<Path> paths = Files.walk(dir)) {
831            paths.filter(Files::isRegularFile)
832                 .filter(this::isWorkflowFile)
833                 .forEach(this::registerWorkflowFile);
834        } catch (IOException e) {
835            LOG.warning("Error scanning directory: " + e.getMessage());
836        }
837    }
838
839    /**
840     * Checks if a file is a workflow file (YAML, JSON, or XML).
841     */
842    private boolean isWorkflowFile(Path path) {
843        String name = path.getFileName().toString().toLowerCase();
844        return name.endsWith(".yaml") || name.endsWith(".yml") ||
845               name.endsWith(".json") || name.endsWith(".xml");
846    }
847
848    /**
849     * Registers a workflow file in the cache.
850     */
851    private void registerWorkflowFile(Path path) {
852        File file = path.toFile();
853        String fileName = file.getName();
854
855        // Register with full filename
856        workflowCache.put(fileName, file);
857
858        // Register without extension
859        int dotIndex = fileName.lastIndexOf('.');
860        if (dotIndex > 0) {
861            String baseName = fileName.substring(0, dotIndex);
862            // Only register base name if not already taken by another file
863            workflowCache.putIfAbsent(baseName, file);
864        }
865    }
866
867    /**
868     * Finds a workflow file by name.
869     */
870    public File findWorkflowFile(String name) {
871        // Try exact match first
872        File file = workflowCache.get(name);
873        if (file != null) {
874            return file;
875        }
876
877        // Try with common extensions
878        String[] extensions = {".yaml", ".yml", ".json", ".xml"};
879        for (String ext : extensions) {
880            file = workflowCache.get(name + ext);
881            if (file != null) {
882                return file;
883            }
884        }
885
886        return null;
887    }
888
889    /**
890     * Loads the main workflow file with optional overlay support.
891     */
892    private ActionResult loadMainWorkflow(NodeGroupIIAR nodeGroupActor, File workflowFile, File overlayDir) {
893        LOG.info("Loading workflow: " + workflowFile.getAbsolutePath());
894        logToDb("cli", LogLevel.INFO, "Loading workflow: " + workflowFile.getName());
895
896        String loadArg;
897        if (overlayDir != null) {
898            // Pass both workflow path and overlay directory
899            loadArg = "[\"" + workflowFile.getAbsolutePath() + "\", \"" + overlayDir.getAbsolutePath() + "\"]";
900            LOG.fine("Loading with overlay: " + overlayDir.getAbsolutePath());
901        } else {
902            // Load without overlay
903            loadArg = "[\"" + workflowFile.getAbsolutePath() + "\"]";
904        }
905
906        return nodeGroupActor.callByActionName("readYaml", loadArg);
907    }
908
909    private void printWorkflowList() {
910        List<WorkflowDisplay> displays = scanWorkflowsForDisplay(workflowDir);
911
912        if (displays.isEmpty()) {
913            System.out.println("No workflow files found under "
914                + workflowDir.getAbsolutePath());
915            return;
916        }
917
918        System.out.println("Available workflows (directory: "
919            + workflowDir.getAbsolutePath() + ")");
920        System.out.println("-".repeat(90));
921        System.out.printf("%-4s %-25s %-35s %s%n", "#", "File (-w)", "Path", "Workflow Name (in logs)");
922        System.out.println("-".repeat(90));
923        int index = 1;
924        for (WorkflowDisplay display : displays) {
925            String wfName = display.workflowName() != null ? display.workflowName() : "(no name)";
926            System.out.printf("%2d.  %-25s %-35s %s%n",
927                index++, display.baseName(), display.relativePath(), wfName);
928        }
929        System.out.println("-".repeat(90));
930        System.out.println("Use -w <File> with the names shown in the 'File (-w)' column.");
931    }
932
933    private static String getBaseName(String fileName) {
934        int dotIndex = fileName.lastIndexOf('.');
935        if (dotIndex > 0) {
936            return fileName.substring(0, dotIndex);
937        }
938        return fileName;
939    }
940
941    private static String relativize(Path base, Path target) {
942        try {
943            return base.relativize(target).toString();
944        } catch (IllegalArgumentException e) {
945            return target.toAbsolutePath().toString();
946        }
947    }
948
949    private static List<WorkflowDisplay> scanWorkflowsForDisplay(File directory) {
950        if (directory == null) {
951            return List.of();
952        }
953
954        try (Stream<Path> paths = Files.walk(directory.toPath())) {
955            Map<String, File> uniqueFiles = new LinkedHashMap<>();
956            paths.filter(Files::isRegularFile)
957                 .filter(path -> {
958                     String name = path.getFileName().toString().toLowerCase();
959                     return name.endsWith(".yaml") || name.endsWith(".yml")
960                         || name.endsWith(".json") || name.endsWith(".xml");
961                 })
962                 .forEach(path -> uniqueFiles.putIfAbsent(path.toFile().getAbsolutePath(), path.toFile()));
963
964            Path basePath = directory.toPath();
965            return uniqueFiles.values().stream()
966                .map(file -> new WorkflowDisplay(
967                    getBaseName(file.getName()),
968                    relativize(basePath, file.toPath()),
969                    extractWorkflowName(file)))
970                .sorted(Comparator.comparing(WorkflowDisplay::baseName, String.CASE_INSENSITIVE_ORDER))
971                .collect(Collectors.toList());
972        } catch (IOException e) {
973            System.err.println("Failed to scan workflows: " + e.getMessage());
974            return List.of();
975        }
976    }
977
978    /**
979     * Extracts the workflow name from a YAML/JSON/XML file.
980     */
981    private static String extractWorkflowName(File file) {
982        String fileName = file.getName().toLowerCase();
983        if (fileName.endsWith(".yaml") || fileName.endsWith(".yml")) {
984            return extractNameFromYaml(file);
985        } else if (fileName.endsWith(".json")) {
986            return extractNameFromJson(file);
987        }
988        return null;
989    }
990
991    /**
992     * Extracts name field from YAML file using simple line parsing.
993     */
994    private static String extractNameFromYaml(File file) {
995        try (java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.FileReader(file))) {
996            String line;
997            while ((line = reader.readLine()) != null) {
998                line = line.trim();
999                // Look for "name: value" at the top level (not indented)
1000                if (line.startsWith("name:")) {
1001                    String value = line.substring(5).trim();
1002                    // Remove quotes if present
1003                    if ((value.startsWith("\"") && value.endsWith("\"")) ||
1004                        (value.startsWith("'") && value.endsWith("'"))) {
1005                        value = value.substring(1, value.length() - 1);
1006                    }
1007                    return value.isEmpty() ? null : value;
1008                }
1009                // Stop if we hit a steps: or other major section
1010                if (line.startsWith("steps:") || line.startsWith("vertices:")) {
1011                    break;
1012                }
1013            }
1014        } catch (IOException e) {
1015            // Ignore and return null
1016        }
1017        return null;
1018    }
1019
1020    /**
1021     * Extracts name field from JSON file.
1022     */
1023    private static String extractNameFromJson(File file) {
1024        try (java.io.FileReader reader = new java.io.FileReader(file)) {
1025            org.json.JSONTokener tokener = new org.json.JSONTokener(reader);
1026            org.json.JSONObject json = new org.json.JSONObject(tokener);
1027            return json.optString("name", null);
1028        } catch (Exception e) {
1029            // Ignore and return null
1030        }
1031        return null;
1032    }
1033
1034    /**
1035     * Record for displaying workflow information.
1036     */
1037    private static record WorkflowDisplay(String baseName, String relativePath, String workflowName) {}
1038}