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.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.concurrent.ExecutorService;
034import java.util.logging.Level;
035import java.util.logging.LogManager;
036import java.util.logging.Logger;
037import java.util.stream.Collectors;
038import java.util.stream.Stream;
039import java.util.Comparator;
040
041import com.scivicslab.actoriac.IaCStreamingAccumulator;
042import com.scivicslab.actoriac.NodeGroup;
043import com.scivicslab.actoriac.NodeGroupInterpreter;
044import com.scivicslab.actoriac.NodeGroupIIAR;
045import com.scivicslab.actoriac.accumulator.ConsoleAccumulator;
046import com.scivicslab.actoriac.accumulator.DatabaseAccumulator;
047import com.scivicslab.actoriac.accumulator.FileAccumulator;
048import com.scivicslab.actoriac.accumulator.MultiplexerAccumulator;
049import com.scivicslab.actoriac.accumulator.MultiplexerAccumulatorIIAR;
050import com.scivicslab.actoriac.accumulator.MultiplexerLogHandler;
051import com.scivicslab.actoriac.log.DistributedLogStore;
052import com.scivicslab.actoriac.log.H2LogStore;
053import com.scivicslab.actoriac.log.LogLevel;
054import com.scivicslab.actoriac.log.SessionStatus;
055import com.scivicslab.pojoactor.core.ActionResult;
056import com.scivicslab.pojoactor.core.ActorRef;
057import com.scivicslab.pojoactor.workflow.DynamicActorLoaderActor;
058import com.scivicslab.pojoactor.workflow.IIActorRef;
059import com.scivicslab.pojoactor.workflow.IIActorSystem;
060import com.scivicslab.pojoactor.workflow.kustomize.WorkflowKustomizer;
061
062import picocli.CommandLine;
063import picocli.CommandLine.Command;
064import picocli.CommandLine.Option;
065
066/**
067 * CLI subcommand to execute actor-IaC workflows.
068 *
069 * <p>This is the main workflow execution command. It supports executing workflows
070 * defined in YAML, JSON, or XML format with optional overlay configuration.</p>
071 *
072 * <h2>Usage</h2>
073 * <pre>
074 * actor-iac run -d /path/to/workflows -w main-workflow.yaml
075 * actor-iac run --dir ./workflows --workflow deploy --threads 8
076 * actor-iac run -d ./workflows -w deploy -i inventory.ini -g webservers
077 * </pre>
078 *
079 * @author devteam@scivicslab.com
080 * @since 2.10.0
081 */
082@Command(
083    name = "run",
084    mixinStandardHelpOptions = true,
085    versionProvider = VersionProvider.class,
086    description = "Execute actor-IaC workflows defined in YAML, JSON, or XML format."
087)
088public class RunCLI implements Callable<Integer> {
089
090    private static final Logger LOG = Logger.getLogger(RunCLI.class.getName());
091
092    @Option(
093        names = {"-d", "--dir"},
094        description = "Base directory (logs are created here). Defaults to current directory.",
095        defaultValue = "."
096    )
097    private File workflowDir;
098
099    @Option(
100        names = {"-w", "--workflow"},
101        description = "Workflow file path relative to -d (required)",
102        required = true
103    )
104    private String workflowName;
105
106    @Option(
107        names = {"-i", "--inventory"},
108        description = "Path to Ansible inventory file (enables node-based execution)"
109    )
110    private File inventoryFile;
111
112    @Option(
113        names = {"-g", "--group"},
114        description = "Name of the host group to target (requires --inventory)",
115        defaultValue = "all"
116    )
117    private String groupName;
118
119    @Option(
120        names = {"-t", "--threads"},
121        description = "Number of worker threads for CPU-bound operations (default: ${DEFAULT-VALUE})",
122        defaultValue = "4"
123    )
124    private int threads;
125
126    @Option(
127        names = {"-v", "--verbose"},
128        description = "Enable verbose output"
129    )
130    private boolean verbose;
131
132    @Option(
133        names = {"-o", "--overlay"},
134        description = "Overlay directory containing overlay-conf.yaml for environment-specific configuration"
135    )
136    private File overlayDir;
137
138    @Option(
139        names = {"--file-log", "-l", "--log"},
140        description = "Enable text file logging and specify output path. If not specified, only database logging is used."
141    )
142    private File logFile;
143
144    @Option(
145        names = {"--no-file-log", "--no-log"},
146        description = "Explicitly disable text file logging. (Text logging is disabled by default.)"
147    )
148    private boolean noLog;
149
150    @Option(
151        names = {"--log-db"},
152        description = "H2 database path for distributed logging (default: actor-iac-logs in current directory)"
153    )
154    private File logDbPath;
155
156    @Option(
157        names = {"--no-log-db"},
158        description = "Disable H2 database logging"
159    )
160    private boolean noLogDb;
161
162    @Option(
163        names = {"-k", "--ask-pass"},
164        description = "Prompt for SSH password (uses password authentication instead of ssh-agent)"
165    )
166    private boolean askPass;
167
168    @Option(
169        names = {"--limit"},
170        description = "Limit execution to specific hosts (comma-separated, e.g., '192.168.5.15' or '192.168.5.15,192.168.5.16')"
171    )
172    private String limitHosts;
173
174    @Option(
175        names = {"-L", "--list-workflows"},
176        description = "List workflows discovered under --dir and exit"
177    )
178    private boolean listWorkflows;
179
180    @Option(
181        names = {"-q", "--quiet"},
182        description = "Suppress all console output (stdout/stderr). Logs are still written to database."
183    )
184    private boolean quiet;
185
186    @Option(
187        names = {"--cowfile", "-c"},
188        description = "Cowsay character to use for step display. " +
189                     "Available: tux, dragon, stegosaurus, kitty, bunny, turtle, elephant, " +
190                     "ghostbusters, vader, and 35 more. Use '--cowfile list' to see all."
191    )
192    private String cowfile;
193
194    @Option(
195        names = {"--render-to"},
196        description = "Render overlay-applied workflows to specified directory (does not execute)"
197    )
198    private File renderToDir;
199
200    @Option(
201        names = {"--max-steps", "-m"},
202        description = "Maximum number of state transitions allowed (default: ${DEFAULT-VALUE}). " +
203                     "Use a smaller value for debugging to prevent infinite loops.",
204        defaultValue = "10000"
205    )
206    private int maxSteps;
207
208    /** Cache of discovered workflow files: name -> File */
209    private final Map<String, File> workflowCache = new HashMap<>();
210
211    /** Distributed log store (H2 database) */
212    private DistributedLogStore logStore;
213
214    /** Actor reference for the log store (for async writes) */
215    private ActorRef<DistributedLogStore> logStoreActor;
216
217    /** Dedicated executor service for DB writes */
218    private ExecutorService dbExecutor;
219
220    /** Current session ID for distributed logging */
221    private long sessionId = -1;
222
223    /**
224     * Executes the workflow.
225     *
226     * @return exit code (0 for success, non-zero for failure)
227     */
228    @Override
229    public Integer call() {
230        // Handle --cowfile list: show available cowfiles and exit
231        if ("list".equalsIgnoreCase(cowfile)) {
232            printAvailableCowfiles();
233            return 0;
234        }
235
236        // Validate --dir is required for all other operations
237        if (workflowDir == null) {
238            System.err.println("Missing required option: '--dir=<workflowDir>'");
239            CommandLine.usage(this, System.err);
240            return 2;
241        }
242
243        // Validate required options
244        // --render-to only requires --dir and --overlay, not --workflow
245        if (renderToDir != null) {
246            if (overlayDir == null) {
247                if (!quiet) {
248                    System.err.println("--render-to requires '--overlay=<overlayDir>'");
249                    CommandLine.usage(this, System.err);
250                }
251                return 2;
252            }
253        } else if (workflowName == null && !listWorkflows) {
254            if (!quiet) {
255                System.err.println("Missing required option: '--workflow=<workflowName>'");
256                System.err.println("Use --list-workflows to see available workflows.");
257                CommandLine.usage(this, System.err);
258            }
259            return 2;
260        }
261
262        // In quiet mode, suppress all console output
263        if (quiet) {
264            suppressConsoleOutput();
265        }
266
267        // Configure log level based on verbose flag
268        configureLogLevel(verbose);
269
270        // Setup H2 log database (enabled by default, use --no-log-db to disable)
271        if (!noLogDb) {
272            if (logDbPath == null) {
273                // Default: actor-iac-logs in current directory
274                logDbPath = new File("actor-iac-logs");
275            }
276            try {
277                setupLogDatabase();
278            } catch (SQLException e) {
279                if (!quiet) {
280                    System.err.println("Warning: Failed to setup log database: " + e.getMessage());
281                }
282            }
283        }
284
285        // Setup text file logging via H2LogStore (only when -l/--log option is specified)
286        if (logFile != null && logStore != null) {
287            try {
288                setupTextLogging();
289            } catch (IOException e) {
290                if (!quiet) {
291                    System.err.println("Warning: Failed to setup text file logging: " + e.getMessage());
292                }
293            }
294        }
295
296        try {
297            return executeMain();
298        } finally {
299            // Clean up resources (H2LogStore.close() also closes text log writer)
300            // Clear singleton before closing
301            DistributedLogStore.setInstance(null);
302            if (logStore != null) {
303                try {
304                    logStore.close();
305                } catch (Exception e) {
306                    System.err.println("Warning: Failed to close log database: " + e.getMessage());
307                }
308            }
309        }
310    }
311
312    /**
313     * Suppresses all console output (stdout/stderr) for quiet mode.
314     */
315    private void suppressConsoleOutput() {
316        // Remove ConsoleHandler from root logger
317        Logger rootLogger = Logger.getLogger("");
318        for (java.util.logging.Handler handler : rootLogger.getHandlers()) {
319            if (handler instanceof java.util.logging.ConsoleHandler) {
320                rootLogger.removeHandler(handler);
321            }
322        }
323
324        // Redirect System.out and System.err to null
325        java.io.PrintStream nullStream = new java.io.PrintStream(java.io.OutputStream.nullOutputStream());
326        System.setOut(nullStream);
327        System.setErr(nullStream);
328    }
329
330    /**
331     * Sets up H2 database for distributed logging.
332     *
333     * <p>Uses H2's AUTO_SERVER mode which automatically handles multiple processes
334     * accessing the same database. The first process starts a TCP server, and
335     * subsequent processes connect to it automatically.</p>
336     */
337    private void setupLogDatabase() throws SQLException {
338        Path dbPath = logDbPath.toPath();
339        logStore = new H2LogStore(dbPath);
340        // Set singleton for WorkflowReporter and other components
341        DistributedLogStore.setInstance(logStore);
342        System.out.println("Log database: " + logDbPath.getAbsolutePath() + ".mv.db");
343    }
344
345    /**
346     * Sets up text file logging via H2LogStore.
347     *
348     * <p>This method configures the H2LogStore to also write log entries
349     * to a text file, in addition to the H2 database. Only called when
350     * -l/--log option is explicitly specified.</p>
351     */
352    private void setupTextLogging() throws IOException {
353        Path logFilePath = logFile.toPath();
354
355        // Configure H2LogStore to also write to text file
356        ((H2LogStore) logStore).setTextLogFile(logFilePath);
357
358        LOG.info("Text logging enabled: " + logFilePath);
359    }
360
361    /**
362     * Configures log level based on verbose flag.
363     */
364    private void configureLogLevel(boolean verbose) {
365        Level targetLevel = verbose ? Level.FINER : Level.INFO;
366
367        // Try to load logging.properties from classpath if verbose mode
368        if (verbose) {
369            try (java.io.InputStream is = getClass().getClassLoader().getResourceAsStream("logging.properties")) {
370                if (is != null) {
371                    LogManager.getLogManager().readConfiguration(is);
372                    LOG.info("Loaded logging.properties from classpath");
373                }
374            } catch (java.io.IOException e) {
375                LOG.fine("Could not load logging.properties: " + e.getMessage());
376            }
377        }
378
379        // Configure root logger level
380        Logger rootLogger = Logger.getLogger("");
381        rootLogger.setLevel(targetLevel);
382
383        // Configure handlers to respect the new level
384        for (java.util.logging.Handler handler : rootLogger.getHandlers()) {
385            handler.setLevel(targetLevel);
386        }
387
388        // Configure POJO-actor loggers for tracing
389        Logger interpreterLogger = Logger.getLogger("com.scivicslab.pojoactor.workflow.Interpreter");
390        interpreterLogger.setLevel(targetLevel);
391        Logger actorSystemLogger = Logger.getLogger("com.scivicslab.pojoactor.workflow.IIActorSystem");
392        actorSystemLogger.setLevel(targetLevel);
393        Logger genericIIARLogger = Logger.getLogger("com.scivicslab.pojoactor.workflow.DynamicActorLoaderActor$GenericIIAR");
394        genericIIARLogger.setLevel(targetLevel);
395
396        if (verbose) {
397            LOG.info("Verbose mode enabled - log level set to FINER (tracing enabled)");
398        }
399    }
400
401    /**
402     * Main execution logic.
403     */
404    private Integer executeMain() {
405        // Validate workflow directory
406        if (!workflowDir.exists()) {
407            LOG.severe("Workflow directory does not exist: " + workflowDir);
408            return 1;
409        }
410
411        if (!workflowDir.isDirectory()) {
412            LOG.severe("Not a directory: " + workflowDir);
413            return 1;
414        }
415
416        // Scan workflow directory recursively
417        LOG.info("Scanning workflow directory: " + workflowDir.getAbsolutePath());
418        scanWorkflowDirectory(workflowDir.toPath());
419
420        if (verbose) {
421            LOG.info("Discovered " + workflowCache.size() + " workflow files:");
422            workflowCache.forEach((name, file) ->
423                LOG.info("  " + name + " -> " + file.getPath()));
424        }
425
426        if (listWorkflows) {
427            printWorkflowList();
428            return 0;
429        }
430
431        // Handle --render-to option
432        if (renderToDir != null) {
433            return renderOverlayWorkflows();
434        }
435
436        if (workflowName == null || workflowName.isBlank()) {
437            LOG.severe("Workflow name is required unless --list-workflows is specified.");
438            return 1;
439        }
440
441        // Find main workflow
442        File mainWorkflowFile = findWorkflowFile(workflowName);
443        if (mainWorkflowFile == null) {
444            LOG.severe("Workflow not found: " + workflowName);
445            LOG.info("Available workflows: " + workflowCache.keySet());
446            return 1;
447        }
448
449        // Validate overlay directory if specified
450        if (overlayDir != null) {
451            if (!overlayDir.exists()) {
452                LOG.severe("Overlay directory does not exist: " + overlayDir);
453                return 1;
454            }
455            if (!overlayDir.isDirectory()) {
456                LOG.severe("Overlay path is not a directory: " + overlayDir);
457                return 1;
458            }
459        }
460
461        LOG.info("=== actor-IaC Workflow CLI ===");
462        LOG.info("Workflow directory: " + workflowDir.getAbsolutePath());
463        LOG.info("Main workflow: " + mainWorkflowFile.getName());
464        if (overlayDir != null) {
465            LOG.info("Overlay: " + overlayDir.getAbsolutePath());
466        }
467        LOG.info("Worker threads: " + threads);
468
469        // Execute workflow (use local inventory if none specified)
470        return executeWorkflow(mainWorkflowFile);
471    }
472
473    /**
474     * Renders overlay-applied workflows to the specified directory.
475     */
476    private Integer renderOverlayWorkflows() {
477        if (overlayDir == null) {
478            LOG.severe("--render-to requires --overlay to be specified");
479            return 1;
480        }
481
482        try {
483            // Create output directory if it doesn't exist
484            if (!renderToDir.exists()) {
485                if (!renderToDir.mkdirs()) {
486                    LOG.severe("Failed to create output directory: " + renderToDir);
487                    return 1;
488                }
489            }
490
491            LOG.info("=== Rendering Overlay-Applied Workflows ===");
492            LOG.info("Overlay directory: " + overlayDir.getAbsolutePath());
493            LOG.info("Output directory: " + renderToDir.getAbsolutePath());
494
495            WorkflowKustomizer kustomizer = new WorkflowKustomizer();
496            Map<String, Map<String, Object>> workflows = kustomizer.build(overlayDir.toPath());
497
498            org.yaml.snakeyaml.Yaml yaml = new org.yaml.snakeyaml.Yaml();
499
500            for (Map.Entry<String, Map<String, Object>> entry : workflows.entrySet()) {
501                String fileName = entry.getKey();
502                Map<String, Object> workflowContent = entry.getValue();
503
504                Path outputPath = renderToDir.toPath().resolve(fileName);
505
506                // Add header comment
507                StringBuilder sb = new StringBuilder();
508                sb.append("# Rendered from overlay: ").append(overlayDir.getName()).append("\n");
509                sb.append("# Source: ").append(fileName).append("\n");
510                sb.append("# Generated: ").append(LocalDateTime.now()).append("\n");
511                sb.append("#\n");
512                sb.append(yaml.dump(workflowContent));
513
514                Files.writeString(outputPath, sb.toString());
515                LOG.info("  Written: " + outputPath);
516            }
517
518            LOG.info("Rendered " + workflows.size() + " workflow(s) to " + renderToDir.getAbsolutePath());
519            return 0;
520
521        } catch (IOException e) {
522            LOG.severe("Failed to render workflows: " + e.getMessage());
523            return 1;
524        } catch (Exception e) {
525            LOG.severe("Error during rendering: " + e.getMessage());
526            e.printStackTrace();
527            return 1;
528        }
529    }
530
531    /**
532     * Executes workflow with node-based execution.
533     */
534    private Integer executeWorkflow(File mainWorkflowFile) {
535        NodeGroup nodeGroup;
536
537        // Prompt for password if --ask-pass is specified
538        String sshPassword = null;
539        if (askPass) {
540            sshPassword = promptForPassword("SSH password: ");
541            if (sshPassword == null) {
542                LOG.severe("Password input cancelled");
543                return 1;
544            }
545        }
546
547        IIActorSystem system = new IIActorSystem("actor-iac-cli", threads);
548
549        // Add dedicated thread pool for DB writes (single thread to serialize writes)
550        system.addManagedThreadPool(1);
551        dbExecutor = system.getManagedThreadPool(1);
552
553        // Create logStore actor if log database is configured
554        if (logStore != null) {
555            logStoreActor = system.actorOf("logStore", logStore);
556
557            // Start log session with execution context
558            String overlayName = overlayDir != null ? overlayDir.getName() : null;
559            String inventoryName = inventoryFile != null ? inventoryFile.getName() : null;
560
561            // Collect execution context for reproducibility
562            String cwd = System.getProperty("user.dir");
563            String gitCommit = getGitCommit(workflowDir);
564            String gitBranch = getGitBranch(workflowDir);
565            String commandLine = buildCommandLine();
566            String actorIacVersion = com.scivicslab.actoriac.Version.get();
567            String actorIacCommit = getActorIacCommit();
568
569            sessionId = logStore.startSession(workflowName, overlayName, inventoryName, 1,
570                    cwd, gitCommit, gitBranch, commandLine, actorIacVersion, actorIacCommit);
571            logStore.log(sessionId, "cli", LogLevel.INFO, "Starting workflow: " + workflowName);
572        }
573
574        try {
575            if (inventoryFile != null) {
576                // Use specified inventory file
577                if (!inventoryFile.exists()) {
578                    LOG.severe("Inventory file does not exist: " + inventoryFile);
579                    logToDb("cli", LogLevel.ERROR, "Inventory file does not exist: " + inventoryFile);
580                    return 1;
581                }
582                LOG.info("Inventory: " + inventoryFile.getAbsolutePath());
583                nodeGroup = new NodeGroup.Builder()
584                    .withInventory(new FileInputStream(inventoryFile))
585                    .build();
586            } else {
587                // Use empty NodeGroup for local execution
588                LOG.info("Inventory: (none - using localhost)");
589                nodeGroup = new NodeGroup();
590            }
591
592            // Set SSH password if provided
593            if (sshPassword != null) {
594                nodeGroup.setSshPassword(sshPassword);
595                LOG.info("Authentication: password");
596            } else {
597                LOG.info("Authentication: ssh-agent");
598            }
599
600            // Set host limit if provided
601            if (limitHosts != null) {
602                nodeGroup.setHostLimit(limitHosts);
603                LOG.info("Host limit: " + limitHosts);
604            }
605
606            // Step 1: Create NodeGroupInterpreter
607            NodeGroupInterpreter nodeGroupInterpreter = new NodeGroupInterpreter(nodeGroup, system);
608            // Set workflow base dir to the directory containing the main workflow file
609            // so that sub-workflows can be found relative to the main workflow
610            File workflowBaseDir = mainWorkflowFile.getParentFile();
611            if (workflowBaseDir == null) {
612                workflowBaseDir = workflowDir;
613            }
614            nodeGroupInterpreter.setWorkflowBaseDir(workflowBaseDir.getAbsolutePath());
615            if (overlayDir != null) {
616                nodeGroupInterpreter.setOverlayDir(overlayDir.getAbsolutePath());
617            }
618            if (verbose) {
619                nodeGroupInterpreter.setVerbose(true);
620            }
621            // Create IaCStreamingAccumulator for cowsay display
622            IaCStreamingAccumulator accumulator = new IaCStreamingAccumulator();
623            if (cowfile != null && !cowfile.isBlank()) {
624                accumulator.setCowfile(cowfile);
625            }
626            nodeGroupInterpreter.setAccumulator(accumulator);
627
628            // Inject log store into interpreter for node-level logging
629            if (logStore != null) {
630                nodeGroupInterpreter.setLogStore(logStore, logStoreActor, dbExecutor, sessionId);
631            }
632
633            // Step 2: Create NodeGroupIIAR and register with system
634            NodeGroupIIAR nodeGroupActor = new NodeGroupIIAR("nodeGroup", nodeGroupInterpreter, system);
635            system.addIIActor(nodeGroupActor);
636
637            // Step 2.5: Create and register MultiplexerAccumulatorIIAR
638            MultiplexerAccumulator multiplexer = new MultiplexerAccumulator();
639
640            // Add ConsoleAccumulator (unless --quiet is specified)
641            if (!quiet) {
642                multiplexer.addTarget(new ConsoleAccumulator());
643            }
644
645            // Add FileAccumulator if --file-log is specified
646            if (logFile != null) {
647                try {
648                    multiplexer.addTarget(new FileAccumulator(logFile.toPath()));
649                    LOG.info("File logging enabled: " + logFile.getAbsolutePath());
650                } catch (IOException e) {
651                    LOG.warning("Failed to create file accumulator: " + e.getMessage());
652                }
653            }
654
655            // Add DatabaseAccumulator if log store is configured
656            if (logStoreActor != null && sessionId >= 0) {
657                multiplexer.addTarget(new DatabaseAccumulator(logStoreActor, dbExecutor, sessionId));
658                LOG.fine("Database logging enabled for session: " + sessionId);
659            }
660
661            // Register multiplexer actor with the system
662            MultiplexerAccumulatorIIAR multiplexerActor = new MultiplexerAccumulatorIIAR(
663                "outputMultiplexer", multiplexer, system);
664            system.addIIActor(multiplexerActor);
665            LOG.fine("MultiplexerAccumulator registered as 'outputMultiplexer'");
666
667            // Step 2.6: Create and register DynamicActorLoaderActor for plugin loading
668            DynamicActorLoaderActor loaderActor = new DynamicActorLoaderActor(system);
669            IIActorRef<DynamicActorLoaderActor> loaderRef = new IIActorRef<>("loader", loaderActor, system) {
670                @Override
671                public ActionResult callByActionName(String actionName, String args) {
672                    return loaderActor.callByActionName(actionName, args);
673                }
674            };
675            system.addIIActor(loaderRef);
676            LOG.fine("DynamicActorLoaderActor registered as 'loader'");
677
678            // Add log handler to forward java.util.logging to multiplexer
679            MultiplexerLogHandler logHandler = new MultiplexerLogHandler(system);
680            logHandler.setLevel(java.util.logging.Level.ALL);
681            java.util.logging.Logger.getLogger("").addHandler(logHandler);
682
683            // Step 3: Load the main workflow (with overlay if specified)
684            ActionResult loadResult = loadMainWorkflow(nodeGroupActor, mainWorkflowFile, overlayDir);
685            if (!loadResult.isSuccess()) {
686                LOG.severe("Failed to load workflow: " + loadResult.getResult());
687                logToDb("cli", LogLevel.ERROR, "Failed to load workflow: " + loadResult.getResult());
688                endSession(SessionStatus.FAILED);
689                return 1;
690            }
691
692            // Step 3.5: Create node actors
693            // If no inventory file is specified, use "local" group for localhost execution
694            String effectiveGroup = (inventoryFile == null) ? "local" : groupName;
695            if (effectiveGroup != null) {
696                LOG.info("Creating node actors for group: " + effectiveGroup);
697                logToDb("cli", LogLevel.INFO, "Creating node actors for group: " + effectiveGroup);
698                ActionResult createResult = nodeGroupActor.callByActionName("createNodeActors", "[\"" + effectiveGroup + "\"]");
699                if (!createResult.isSuccess()) {
700                    LOG.severe("Failed to create node actors: " + createResult.getResult());
701                    logToDb("cli", LogLevel.ERROR, "Failed to create node actors: " + createResult.getResult());
702                    endSession(SessionStatus.FAILED);
703                    return 1;
704                }
705                LOG.info("Node actors created: " + createResult.getResult());
706            }
707
708            // Step 4: Execute the workflow
709            LOG.info("Starting workflow execution...");
710            LOG.info("Max steps: " + maxSteps);
711            LOG.info("-".repeat(50));
712            logToDb("cli", LogLevel.INFO, "Starting workflow execution (max steps: " + maxSteps + ")");
713
714            long startTime = System.currentTimeMillis();
715            ActionResult result = nodeGroupActor.callByActionName("runUntilEnd", "[" + maxSteps + "]");
716            long duration = System.currentTimeMillis() - startTime;
717
718            LOG.info("-".repeat(50));
719            if (result.isSuccess()) {
720                LOG.info("Workflow completed successfully: " + result.getResult());
721                logToDb("cli", LogLevel.INFO, "Workflow completed successfully in " + duration + "ms");
722                endSession(SessionStatus.COMPLETED);
723                return 0;
724            } else {
725                LOG.severe("Workflow failed: " + result.getResult());
726                logToDb("cli", LogLevel.ERROR, "Workflow failed: " + result.getResult());
727                endSession(SessionStatus.FAILED);
728                return 1;
729            }
730
731        } catch (Exception e) {
732            LOG.log(Level.SEVERE, "Workflow execution failed", e);
733            logToDb("cli", LogLevel.ERROR, "Exception: " + e.getMessage());
734            endSession(SessionStatus.FAILED);
735            return 1;
736        } finally {
737            system.terminate();
738        }
739    }
740
741    /**
742     * Logs a message to the distributed log store if available.
743     */
744    private void logToDb(String nodeId, LogLevel level, String message) {
745        if (logStore != null && sessionId >= 0) {
746            logStore.log(sessionId, nodeId, level, message);
747        }
748    }
749
750    /**
751     * Ends the current session with the given status.
752     */
753    private void endSession(SessionStatus status) {
754        if (logStore != null && sessionId >= 0) {
755            logStore.endSession(sessionId, status);
756        }
757    }
758
759    /**
760     * Prompts for a password from the console.
761     */
762    private String promptForPassword(String prompt) {
763        java.io.Console console = System.console();
764        if (console != null) {
765            // Secure input - password not echoed
766            char[] passwordChars = console.readPassword(prompt);
767            if (passwordChars != null) {
768                return new String(passwordChars);
769            }
770            return null;
771        } else {
772            // Fallback for environments without console (e.g., IDE)
773            System.out.print(prompt);
774            try (java.util.Scanner scanner = new java.util.Scanner(System.in)) {
775                if (scanner.hasNextLine()) {
776                    return scanner.nextLine();
777                }
778            }
779            return null;
780        }
781    }
782
783    /**
784     * Scans the immediate directory for workflow files (non-recursive).
785     *
786     * @since 2.12.1
787     */
788    private void scanWorkflowDirectory(Path dir) {
789        try (Stream<Path> paths = Files.list(dir)) {
790            paths.filter(Files::isRegularFile)
791                 .filter(this::isWorkflowFile)
792                 .forEach(this::registerWorkflowFile);
793        } catch (IOException e) {
794            LOG.warning("Error scanning directory: " + e.getMessage());
795        }
796    }
797
798    /**
799     * Checks if a file is a workflow file (YAML, JSON, or XML).
800     */
801    private boolean isWorkflowFile(Path path) {
802        String name = path.getFileName().toString().toLowerCase();
803        return name.endsWith(".yaml") || name.endsWith(".yml") ||
804               name.endsWith(".json") || name.endsWith(".xml");
805    }
806
807    /**
808     * Registers a workflow file in the cache.
809     */
810    private void registerWorkflowFile(Path path) {
811        File file = path.toFile();
812        String fileName = file.getName();
813
814        // Register with full filename
815        workflowCache.put(fileName, file);
816
817        // Register without extension
818        int dotIndex = fileName.lastIndexOf('.');
819        if (dotIndex > 0) {
820            String baseName = fileName.substring(0, dotIndex);
821            // Only register base name if not already taken by another file
822            workflowCache.putIfAbsent(baseName, file);
823        }
824    }
825
826    /**
827     * Finds a workflow file by path relative to workflowDir.
828     *
829     * <p>Supports paths like "sysinfo/main-collect-sysinfo.yaml" or
830     * "sysinfo/main-collect-sysinfo" (extension auto-detected).</p>
831     *
832     * @param path workflow file path relative to workflowDir
833     * @return the workflow file, or null if not found
834     * @since 2.12.1
835     */
836    public File findWorkflowFile(String path) {
837        // Resolve path relative to workflowDir
838        File file = new File(workflowDir, path);
839
840        // Try exact path first
841        if (file.isFile()) {
842            return file;
843        }
844
845        // Try with common extensions
846        String[] extensions = {".yaml", ".yml", ".json", ".xml"};
847        for (String ext : extensions) {
848            File candidate = new File(workflowDir, path + ext);
849            if (candidate.isFile()) {
850                return candidate;
851            }
852        }
853
854        return null;
855    }
856
857    /**
858     * Loads the main workflow file with optional overlay support.
859     */
860    private ActionResult loadMainWorkflow(NodeGroupIIAR nodeGroupActor, File workflowFile, File overlayDir) {
861        LOG.info("Loading workflow: " + workflowFile.getAbsolutePath());
862        logToDb("cli", LogLevel.INFO, "Loading workflow: " + workflowFile.getName());
863
864        String loadArg;
865        if (overlayDir != null) {
866            // Pass both workflow path and overlay directory
867            loadArg = "[\"" + workflowFile.getAbsolutePath() + "\", \"" + overlayDir.getAbsolutePath() + "\"]";
868            LOG.fine("Loading with overlay: " + overlayDir.getAbsolutePath());
869        } else {
870            // Load without overlay
871            loadArg = "[\"" + workflowFile.getAbsolutePath() + "\"]";
872        }
873
874        return nodeGroupActor.callByActionName("readYaml", loadArg);
875    }
876
877    private void printWorkflowList() {
878        List<WorkflowDisplay> displays = scanWorkflowsForDisplay(workflowDir);
879
880        if (displays.isEmpty()) {
881            System.out.println("No workflow files found under "
882                + workflowDir.getAbsolutePath());
883            return;
884        }
885
886        System.out.println("Available workflows (directory: "
887            + workflowDir.getAbsolutePath() + ")");
888        System.out.println("-".repeat(90));
889        System.out.printf("%-4s %-25s %-35s %s%n", "#", "File (-w)", "Path", "Workflow Name (in logs)");
890        System.out.println("-".repeat(90));
891        int index = 1;
892        for (WorkflowDisplay display : displays) {
893            String wfName = display.workflowName() != null ? display.workflowName() : "(no name)";
894            System.out.printf("%2d.  %-25s %-35s %s%n",
895                index++, display.baseName(), display.relativePath(), wfName);
896        }
897        System.out.println("-".repeat(90));
898        System.out.println("Use -w <File> with the names shown in the 'File (-w)' column.");
899    }
900
901    /**
902     * Prints available cowfiles for cowsay output customization.
903     */
904    private void printAvailableCowfiles() {
905        System.out.println("Available cowfiles for --cowfile option:");
906        System.out.println("=".repeat(70));
907        System.out.println();
908
909        // Get list from Cowsay library
910        String[] listArgs = { "-l" };
911        String cowList = com.github.ricksbrown.cowsay.Cowsay.say(listArgs);
912
913        // Split and format nicely
914        String[] cowfiles = cowList.trim().split("\\s+");
915        System.out.println("Total: " + cowfiles.length + " cowfiles");
916        System.out.println();
917
918        // Print in columns
919        int cols = 4;
920        int colWidth = 17;
921        for (int i = 0; i < cowfiles.length; i++) {
922            System.out.printf("%-" + colWidth + "s", cowfiles[i]);
923            if ((i + 1) % cols == 0) {
924                System.out.println();
925            }
926        }
927        if (cowfiles.length % cols != 0) {
928            System.out.println();
929        }
930
931        System.out.println();
932        System.out.println("=".repeat(70));
933        System.out.println("Usage: actor_iac.java run -d <dir> -w <workflow> --cowfile tux");
934        System.out.println();
935        System.out.println("Popular choices:");
936        System.out.println("  tux         - Linux penguin (great for server work)");
937        System.out.println("  dragon      - Majestic dragon");
938        System.out.println("  stegosaurus - Prehistoric dinosaur");
939        System.out.println("  turtle      - Slow and steady");
940        System.out.println("  ghostbusters - Who you gonna call?");
941    }
942
943    private static String getBaseName(String fileName) {
944        int dotIndex = fileName.lastIndexOf('.');
945        if (dotIndex > 0) {
946            return fileName.substring(0, dotIndex);
947        }
948        return fileName;
949    }
950
951    private static String relativize(Path base, Path target) {
952        try {
953            return base.relativize(target).toString();
954        } catch (IllegalArgumentException e) {
955            return target.toAbsolutePath().toString();
956        }
957    }
958
959    /**
960     * Scans the immediate directory for workflow files (non-recursive).
961     *
962     * @since 2.12.1
963     */
964    private static List<WorkflowDisplay> scanWorkflowsForDisplay(File directory) {
965        if (directory == null) {
966            return List.of();
967        }
968
969        try (Stream<Path> paths = Files.list(directory.toPath())) {
970            Map<String, File> uniqueFiles = new LinkedHashMap<>();
971            paths.filter(Files::isRegularFile)
972                 .filter(path -> {
973                     String name = path.getFileName().toString().toLowerCase();
974                     return name.endsWith(".yaml") || name.endsWith(".yml")
975                         || name.endsWith(".json") || name.endsWith(".xml");
976                 })
977                 .forEach(path -> uniqueFiles.putIfAbsent(path.toFile().getAbsolutePath(), path.toFile()));
978
979            Path basePath = directory.toPath();
980            return uniqueFiles.values().stream()
981                .map(file -> new WorkflowDisplay(
982                    getBaseName(file.getName()),
983                    relativize(basePath, file.toPath()),
984                    extractWorkflowName(file)))
985                .sorted(Comparator.comparing(WorkflowDisplay::baseName, String.CASE_INSENSITIVE_ORDER))
986                .collect(Collectors.toList());
987        } catch (IOException e) {
988            System.err.println("Failed to scan workflows: " + e.getMessage());
989            return List.of();
990        }
991    }
992
993    /**
994     * Extracts the workflow name from a YAML/JSON/XML file.
995     */
996    private static String extractWorkflowName(File file) {
997        String fileName = file.getName().toLowerCase();
998        if (fileName.endsWith(".yaml") || fileName.endsWith(".yml")) {
999            return extractNameFromYaml(file);
1000        } else if (fileName.endsWith(".json")) {
1001            return extractNameFromJson(file);
1002        }
1003        return null;
1004    }
1005
1006    /**
1007     * Extracts name field from YAML file using simple line parsing.
1008     */
1009    private static String extractNameFromYaml(File file) {
1010        try (java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.FileReader(file))) {
1011            String line;
1012            while ((line = reader.readLine()) != null) {
1013                line = line.trim();
1014                // Look for "name: value" at the top level (not indented)
1015                if (line.startsWith("name:")) {
1016                    String value = line.substring(5).trim();
1017                    // Remove quotes if present
1018                    if ((value.startsWith("\"") && value.endsWith("\"")) ||
1019                        (value.startsWith("'") && value.endsWith("'"))) {
1020                        value = value.substring(1, value.length() - 1);
1021                    }
1022                    return value.isEmpty() ? null : value;
1023                }
1024                // Stop if we hit a steps: or other major section
1025                if (line.startsWith("steps:") || line.startsWith("vertices:")) {
1026                    break;
1027                }
1028            }
1029        } catch (IOException e) {
1030            // Ignore and return null
1031        }
1032        return null;
1033    }
1034
1035    /**
1036     * Extracts name field from JSON file.
1037     */
1038    private static String extractNameFromJson(File file) {
1039        try (java.io.FileReader reader = new java.io.FileReader(file)) {
1040            org.json.JSONTokener tokener = new org.json.JSONTokener(reader);
1041            org.json.JSONObject json = new org.json.JSONObject(tokener);
1042            return json.optString("name", null);
1043        } catch (Exception e) {
1044            // Ignore and return null
1045        }
1046        return null;
1047    }
1048
1049    /**
1050     * Record for displaying workflow information.
1051     */
1052    private static record WorkflowDisplay(String baseName, String relativePath, String workflowName) {}
1053
1054    /**
1055     * Gets the git commit hash of the specified directory.
1056     *
1057     * @param dir directory to check (uses current directory if null)
1058     * @return short git commit hash, or null if not a git repository
1059     */
1060    private String getGitCommit(File dir) {
1061        try {
1062            ProcessBuilder pb = new ProcessBuilder("git", "rev-parse", "--short", "HEAD");
1063            if (dir != null) {
1064                pb.directory(dir);
1065            }
1066            pb.redirectErrorStream(true);
1067            Process p = pb.start();
1068            String output = new String(p.getInputStream().readAllBytes()).trim();
1069            int exitCode = p.waitFor();
1070            return exitCode == 0 && !output.isEmpty() ? output : null;
1071        } catch (Exception e) {
1072            return null;
1073        }
1074    }
1075
1076    /**
1077     * Gets the git branch name of the specified directory.
1078     *
1079     * @param dir directory to check (uses current directory if null)
1080     * @return git branch name, or null if not a git repository
1081     */
1082    private String getGitBranch(File dir) {
1083        try {
1084            ProcessBuilder pb = new ProcessBuilder("git", "rev-parse", "--abbrev-ref", "HEAD");
1085            if (dir != null) {
1086                pb.directory(dir);
1087            }
1088            pb.redirectErrorStream(true);
1089            Process p = pb.start();
1090            String output = new String(p.getInputStream().readAllBytes()).trim();
1091            int exitCode = p.waitFor();
1092            return exitCode == 0 && !output.isEmpty() ? output : null;
1093        } catch (Exception e) {
1094            return null;
1095        }
1096    }
1097
1098    /**
1099     * Builds a command line string from the current options.
1100     *
1101     * @return command line string
1102     */
1103    private String buildCommandLine() {
1104        StringBuilder sb = new StringBuilder("run");
1105        if (workflowDir != null) {
1106            sb.append(" -d ").append(workflowDir.getPath());
1107        }
1108        if (workflowName != null) {
1109            sb.append(" -w ").append(workflowName);
1110        }
1111        if (inventoryFile != null) {
1112            sb.append(" -i ").append(inventoryFile.getPath());
1113        }
1114        if (groupName != null && !groupName.equals("all")) {
1115            sb.append(" -g ").append(groupName);
1116        }
1117        if (overlayDir != null) {
1118            sb.append(" -o ").append(overlayDir.getPath());
1119        }
1120        if (threads != 4) {
1121            sb.append(" -t ").append(threads);
1122        }
1123        if (verbose) {
1124            sb.append(" -v");
1125        }
1126        if (quiet) {
1127            sb.append(" -q");
1128        }
1129        if (limitHosts != null) {
1130            sb.append(" --limit ").append(limitHosts);
1131        }
1132        return sb.toString();
1133    }
1134
1135    /**
1136     * Gets the git commit hash of the actor-IaC installation.
1137     * This looks for a .git directory relative to where actor-IaC is running.
1138     *
1139     * @return short git commit hash, or null if not available
1140     */
1141    private String getActorIacCommit() {
1142        // Try to get commit from the actor-IaC source directory if running in dev mode
1143        try {
1144            String classPath = RunCLI.class.getProtectionDomain().getCodeSource().getLocation().getPath();
1145            File classFile = new File(classPath);
1146            // If running from target/classes, go up to project root
1147            if (classFile.getPath().contains("target")) {
1148                File projectRoot = classFile.getParentFile();
1149                while (projectRoot != null && !new File(projectRoot, ".git").exists()) {
1150                    projectRoot = projectRoot.getParentFile();
1151                }
1152                if (projectRoot != null) {
1153                    return getGitCommit(projectRoot);
1154                }
1155            }
1156        } catch (Exception e) {
1157            // Ignore
1158        }
1159        return null;
1160    }
1161}