From 989498afa56d0b842e558ee1685006f4ae028eff Mon Sep 17 00:00:00 2001 From: Aleksandr Suvorov Date: Sun, 31 May 2026 01:54:45 +0400 Subject: [PATCH] feat(visualize): render SUB_WORKFLOW, parallel, and fork-join nodes in text and Mermaid formats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend `hensu visualize` to fully render compound node types that previously produced empty or minimal output. - SUB_WORKFLOW: inline sub-workflow nodes via --with flag; bordered subgraph in text format, nested Mermaid subgraph with namespaced IDs - ParallelNode: decompose into branch nodes + synthetic join node, mirroring fork-join visual pattern - StandardNode: show rubric criteria count, planning mode, and review gate in both formats - ApprovalTransition: render approved/rejected edges in both formats - ScoreTransition RANGE: render as "score min–max" instead of raw enum - Dark mode Mermaid styling per visual-style-guide - BFS-ordered node declarations for deterministic Mermaid layout - Label escaping to prevent Mermaid syntax breakage Resolve: #69 Signed-off-by: Aleksandr Suvorov --- docs/dsl-reference.md | 2 +- .../commands/WorkflowVisualizeCommand.java | 6 +- .../MermaidVisualizationFormat.java | 368 ++++++++++++++--- .../visualizer/TextVisualizationFormat.java | 205 ++++++--- .../cli/visualizer/VisualizationFormat.java | 13 + .../cli/visualizer/WorkflowVisualizer.java | 29 +- .../WorkflowVisualizeCommandTest.java | 12 +- .../MermaidVisualizationFormatTest.java | 390 ++++++++++++++++-- .../TextVisualizationFormatTest.java | 240 +++++++++++ .../src/main/resources/application.properties | 2 + 10 files changed, 1089 insertions(+), 178 deletions(-) diff --git a/docs/dsl-reference.md b/docs/dsl-reference.md index 6a7a549..485aece 100644 --- a/docs/dsl-reference.md +++ b/docs/dsl-reference.md @@ -542,7 +542,7 @@ Retries up to 3 times before transitioning to the fallback node. ### Score-Based Transitions -Route based on rubric evaluation scores: +Route based on rubric evaluation scores. Conditions are evaluated in declaration order – the first match wins. When using overlapping ranges (e.g., `greaterThanOrEqual 80.0` before `greaterThanOrEqual 50.0`), place the most specific condition first: ```kotlin onScore { diff --git a/hensu-cli/src/main/java/io/hensu/cli/commands/WorkflowVisualizeCommand.java b/hensu-cli/src/main/java/io/hensu/cli/commands/WorkflowVisualizeCommand.java index aacea83..27710e9 100644 --- a/hensu-cli/src/main/java/io/hensu/cli/commands/WorkflowVisualizeCommand.java +++ b/hensu-cli/src/main/java/io/hensu/cli/commands/WorkflowVisualizeCommand.java @@ -4,6 +4,8 @@ import io.hensu.core.workflow.Workflow; import jakarta.inject.Inject; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; import picocli.CommandLine; /// CLI command for rendering workflow graphs in various formats. @@ -49,7 +51,9 @@ class WorkflowVisualizeCommand extends WorkflowCommand { protected void execute() { try { Workflow workflow = getWorkflow(workflowName, withNames); - String output = visualizer.visualize(workflow, format); + Map subMap = + loadedSubWorkflows.stream().collect(Collectors.toMap(Workflow::getId, w -> w)); + String output = visualizer.visualize(workflow, subMap, format); System.out.println(output); } catch (Exception e) { System.err.println(" [FAIL] Visualization failed: " + e.getMessage()); diff --git a/hensu-cli/src/main/java/io/hensu/cli/visualizer/MermaidVisualizationFormat.java b/hensu-cli/src/main/java/io/hensu/cli/visualizer/MermaidVisualizationFormat.java index 2553071..f8e9686 100644 --- a/hensu-cli/src/main/java/io/hensu/cli/visualizer/MermaidVisualizationFormat.java +++ b/hensu-cli/src/main/java/io/hensu/cli/visualizer/MermaidVisualizationFormat.java @@ -1,10 +1,20 @@ package io.hensu.cli.visualizer; +import io.hensu.core.review.ReviewMode; +import io.hensu.core.rubric.model.ComparisonOperator; import io.hensu.core.rubric.model.ScoreCondition; import io.hensu.core.workflow.Workflow; import io.hensu.core.workflow.node.*; import io.hensu.core.workflow.transition.*; import jakarta.enterprise.context.ApplicationScoped; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; /// Mermaid diagram format visualization for workflows. /// @@ -12,7 +22,7 @@ /// in GitHub/GitLab Markdown, documentation tools, or at [mermaid.live](https://mermaid.live). /// /// ### Node Shape Mapping -/// - **StandardNode**: Rectangle with agent label +/// - **StandardNode**: Stadium (pill) shape with agent label /// - **EndNode**: Stadium shape with exit status /// - **LoopNode**: Diamond shape /// - **ParallelNode**: Double rectangle @@ -37,127 +47,345 @@ public String getName() { @Override public String render(Workflow workflow) { + return render(workflow, Map.of()); + } + + private static final String NODE_STYLE = + "fill:#2c2c2e, stroke:#48484a, color:#ebebf5, stroke-width:1px"; + private static final String SUBGRAPH_STYLE = + "fill:#2c2c2e, stroke:#3a3a3c, color:#ebebf5, stroke-width:1px"; + private static final String NESTED_SUBGRAPH_STYLE = + "fill:#3a3a3c, stroke:#48484a, color:#ebebf5, stroke-width:1px"; + private static final String LINK_STYLE = "stroke:#0A84FF, stroke-width:1px"; + + @Override + public String render(Workflow workflow, Map subWorkflows) { StringBuilder sb = new StringBuilder(); + List nodeIds = new ArrayList<>(); + List nestedSubgraphIds = new ArrayList<>(); sb.append("```mermaid\n"); sb.append("flowchart TD\n"); - // Add title as a subgraph + List> ordered = bfsOrder(workflow); + String rootSubgraphId = sanitizeId(workflow.getId()); + sb.append(" subgraph ") - .append(sanitizeId(workflow.getId())) + .append(rootSubgraphId) .append("[\"") - .append(workflow.getMetadata().getName()) + .append(escapeLabel(workflow.getMetadata().getName())) .append("\"]\n"); - // Define nodes with styling - for (var entry : workflow.getNodes().entrySet()) { + for (var entry : ordered) { String nodeId = entry.getKey(); Node node = entry.getValue(); + String id = sanitizeId(nodeId); + nodeIds.add(id); renderNode(sb, nodeId, node); + + if (node instanceof SubWorkflowNode swn) { + Workflow sub = subWorkflows.get(swn.getWorkflowId()); + if (sub != null) { + renderInlinedSubWorkflow(sb, swn, sub, nodeIds, nestedSubgraphIds); + } + } else if (node instanceof ParallelNode pn) { + renderParallelBranches(sb, id, nodeId, pn, nodeIds); + } } sb.append(" end\n\n"); - // Define edges (transitions) - for (var entry : workflow.getNodes().entrySet()) { + for (var entry : ordered) { String nodeId = entry.getKey(); Node node = entry.getValue(); renderEdges(sb, nodeId, node); + + if (node instanceof SubWorkflowNode swn) { + Workflow sub = subWorkflows.get(swn.getWorkflowId()); + if (sub != null) { + String fromId = sanitizeId(nodeId); + String startId = namespacedId(swn.getWorkflowId(), sub.getStartNode()); + sb.append(" ") + .append(fromId) + .append(" -->|sub| ") + .append(startId) + .append("\n"); + + for (var subEntry : bfsOrder(sub)) { + renderEdges( + sb, + namespacedId(swn.getWorkflowId(), subEntry.getKey()), + subEntry.getValue(), + swn.getWorkflowId()); + } + } + } } + sb.append("\n"); + renderStyles(sb, rootSubgraphId, nestedSubgraphIds, nodeIds); + sb.append("```\n"); return sb.toString(); } private void renderNode(StringBuilder sb, String nodeId, Node node) { - String id = sanitizeId(nodeId); + renderNode(sb, sanitizeId(nodeId), nodeId, node); + } + + private void renderInlinedSubWorkflow( + StringBuilder sb, + SubWorkflowNode swn, + Workflow sub, + List nodeIds, + List nestedSubgraphIds) { + String subId = sanitizeId(swn.getWorkflowId()); + nestedSubgraphIds.add(subId); + sb.append(" subgraph ") + .append(subId) + .append("[\"") + .append(escapeLabel(swn.getWorkflowId())) + .append("\"]\n"); + for (var entry : bfsOrder(sub)) { + String nsId = namespacedId(swn.getWorkflowId(), entry.getKey()); + nodeIds.add(nsId); + renderNode(sb, nsId, entry.getKey(), entry.getValue()); + } + sb.append(" end\n"); + } + + private void renderParallelBranches( + StringBuilder sb, + String prefixId, + String displayId, + ParallelNode pn, + List nodeIds) { + for (var branch : pn.getBranches()) { + String branchId = prefixId + "_" + sanitizeId(branch.getId()); + nodeIds.add(branchId); + String label = + escapeLabel(branch.getId()) + "\\n[" + escapeLabel(branch.getAgentId()) + "]"; + sb.append(" ") + .append(branchId) + .append("([\"") + .append(label) + .append("\"])") + .append("\n"); + } + String joinId = prefixId + "___join"; + nodeIds.add(joinId); + String joinLabel = + escapeLabel(displayId) + + "\\n(join" + + (pn.getConsensusConfig() != null + ? ": " + pn.getConsensusConfig().getStrategy() + : "") + + ")"; + sb.append(" ").append(joinId).append("[\"").append(joinLabel).append("\"]").append("\n"); + } + private void renderNode(StringBuilder sb, String id, String displayId, Node node) { + String safeDisplayId = escapeLabel(displayId); String shape = switch (node) { case EndNode tn -> { - String label = nodeId + " (" + tn.getExitStatus() + ")"; + String label = safeDisplayId + " (" + tn.getExitStatus() + ")"; yield id + "([\"" + label + "\"])"; } case StandardNode sn -> { - String label = - sn.getAgentId() != null - ? nodeId + "\\n[" + sn.getAgentId() + "]" - : nodeId; - yield id + "[\"" + label + "\"]"; + var lb = new StringBuilder(safeDisplayId); + if (sn.getAgentId() != null) { + lb.append("\\n[").append(escapeLabel(sn.getAgentId())).append("]"); + } + if (sn.getRubric() != null) { + lb.append("\\n rubric: ") + .append(sn.getRubric().getCriteria().size()) + .append(" criteria"); + } + if (sn.hasPlanningEnabled()) { + lb.append("\\n planning: ").append(sn.getPlanningConfig().mode()); + } + if (sn.getReviewConfig() != null + && sn.getReviewConfig().getMode() != ReviewMode.DISABLED) { + lb.append("\\n review: ").append(sn.getReviewConfig().getMode()); + } + yield id + "([\"" + lb + "\"])"; } case LoopNode ignored -> { - String label = nodeId + " (loop)"; + String label = safeDisplayId + " (loop)"; yield id + "{\"" + label + "\"}"; } - case ParallelNode ignored -> { - String label = nodeId + " (parallel)"; - yield id + "[[\"" + label + "\"]]"; + case ParallelNode pn -> { + String label = + safeDisplayId + "\\n(parallel: " + pn.getBranches().length + ")"; + yield id + ">\"" + label + "\"]"; } case ForkNode fn -> { - String label = nodeId + "\\n(fork: " + fn.getTargets().size() + ")"; + String label = safeDisplayId + "\\n(fork: " + fn.getTargets().size() + ")"; yield id + ">\"" + label + "\"]"; } case JoinNode jn -> { - String label = nodeId + "\\n(join: " + jn.getMergeStrategy() + ")"; - yield id + "[\"" + label + "\"/]"; + String label = safeDisplayId + "\\n(join: " + jn.getMergeStrategy() + ")"; + yield id + "[\"" + label + "\"]"; } case GenericNode gn -> { - String label = nodeId + "\\n[" + gn.getExecutorType() + "]"; + String label = + safeDisplayId + "\\n[" + escapeLabel(gn.getExecutorType()) + "]"; yield id + "{{\"" + label + "\"}}"; } case ActionNode an -> { - String label = nodeId + "\\n(action: " + an.getActions().size() + ")"; + String label = + safeDisplayId + "\\n(action: " + an.getActions().size() + ")"; yield id + "[/\"" + label + "\"/]"; } - default -> id + "[" + nodeId + "]"; + case SubWorkflowNode swn -> { + String label = + safeDisplayId + + "\\n[sub: " + + escapeLabel(swn.getWorkflowId()) + + "]"; + yield id + "[(\"" + label + "\")]"; + } + default -> id + "[" + safeDisplayId + "]"; }; sb.append(" ").append(shape).append("\n"); } + private static void renderStyles( + StringBuilder sb, + String rootSubgraphId, + List nestedSubgraphIds, + List nodeIds) { + sb.append(" style ") + .append(rootSubgraphId) + .append(" ") + .append(SUBGRAPH_STYLE) + .append("\n"); + for (String id : nestedSubgraphIds) { + sb.append(" style ").append(id).append(" ").append(NESTED_SUBGRAPH_STYLE).append("\n"); + } + for (String id : nodeIds) { + sb.append(" style ").append(id).append(" ").append(NODE_STYLE).append("\n"); + } + sb.append(" linkStyle default ").append(LINK_STYLE).append("\n"); + } + + private static List> bfsOrder(Workflow workflow) { + LinkedHashMap ordered = new LinkedHashMap<>(); + Set visited = new HashSet<>(); + Deque queue = new ArrayDeque<>(); + queue.add(workflow.getStartNode()); + + while (!queue.isEmpty()) { + String nodeId = queue.removeFirst(); + if (!visited.add(nodeId)) continue; + + Node node = workflow.getNodes().get(nodeId); + if (node == null) continue; + ordered.put(nodeId, node); + + for (TransitionRule rule : node.getTransitionRules()) { + switch (rule) { + case SuccessTransition s -> queue.add(s.getTargetNode()); + case FailureTransition f -> queue.add(f.getThenTargetNode()); + case ScoreTransition sc -> { + for (var cond : sc.getConditions()) { + queue.add(cond.getTargetNode()); + } + } + case ApprovalTransition a -> queue.add(a.targetNode()); + default -> {} + } + } + + switch (node) { + case LoopNode ln when ln.getBreakRules() != null -> { + for (BreakRule br : ln.getBreakRules()) { + queue.add(br.getTargetNode()); + } + } + case ForkNode fn -> queue.addAll(fn.getTargets()); + default -> {} + } + } + + return new ArrayList<>(ordered.entrySet()); + } + + private static String namespacedId(String workflowId, String nodeId) { + return sanitizeId(workflowId) + "__" + sanitizeId(nodeId); + } + + private void renderEdges(StringBuilder sb, String nodeId, Node node, String workflowPrefix) { + renderEdgesInternal(sb, nodeId, node, workflowPrefix); + } + private void renderEdges(StringBuilder sb, String nodeId, Node node) { String fromId = sanitizeId(nodeId); + renderEdgesInternal(sb, fromId, node, null); + } - if (node instanceof StandardNode standardNode) { - renderTransitionRules(sb, fromId, standardNode.getTransitionRules()); - } else if (node instanceof LoopNode loopNode && loopNode.getBreakRules() != null) { - for (BreakRule rule : loopNode.getBreakRules()) { - String toId = sanitizeId(rule.getTargetNode()); - sb.append(" ").append(fromId).append(" -.->|break| ").append(toId).append("\n"); + private void renderEdgesInternal( + StringBuilder sb, String fromId, Node node, String workflowPrefix) { + + switch (node) { + case LoopNode loopNode when loopNode.getBreakRules() != null -> { + for (BreakRule rule : loopNode.getBreakRules()) { + String toId = resolveTargetId(rule.getTargetNode(), workflowPrefix); + sb.append(" ") + .append(fromId) + .append(" -.->|break| ") + .append(toId) + .append("\n"); + } } - } else if (node instanceof ForkNode forkNode) { - // Render edges to all fork targets - for (String target : forkNode.getTargets()) { - String toId = sanitizeId(target); - sb.append(" ").append(fromId).append(" -->|fork| ").append(toId).append("\n"); + case ForkNode forkNode -> { + for (String target : forkNode.getTargets()) { + String toId = resolveTargetId(target, workflowPrefix); + sb.append(" ").append(fromId).append(" -->|fork| ").append(toId).append("\n"); + } + renderTransitionRules(sb, fromId, forkNode.getTransitionRules(), workflowPrefix); } - // Render transition rules (onComplete) - renderTransitionRules(sb, fromId, forkNode.getTransitionRules()); - } else if (node instanceof JoinNode joinNode) { - // Render await connections (dashed lines showing dependencies) - for (String target : joinNode.getAwaitTargets()) { - String toId = sanitizeId(target); - sb.append(" ").append(toId).append(" -.->|await| ").append(fromId).append("\n"); + case JoinNode joinNode -> { + for (String target : joinNode.getAwaitTargets()) { + String toId = resolveTargetId(target, workflowPrefix); + sb.append(" ") + .append(toId) + .append(" -.->|await| ") + .append(fromId) + .append("\n"); + } + renderTransitionRules(sb, fromId, joinNode.getTransitionRules(), workflowPrefix); } - // Render transition rules - renderTransitionRules(sb, fromId, joinNode.getTransitionRules()); - } else if (node instanceof GenericNode genericNode) { - renderTransitionRules(sb, fromId, genericNode.getTransitionRules()); - } else if (node instanceof ParallelNode parallelNode) { - renderTransitionRules(sb, fromId, parallelNode.getTransitionRules()); - } else if (node instanceof ActionNode actionNode) { - renderTransitionRules(sb, fromId, actionNode.getTransitionRules()); + case ParallelNode pn -> { + for (var branch : pn.getBranches()) { + String branchId = fromId + "_" + sanitizeId(branch.getId()); + sb.append(" ") + .append(fromId) + .append(" -->|branch| ") + .append(branchId) + .append("\n"); + } + String joinId = fromId + "___join"; + for (var branch : pn.getBranches()) { + String branchId = fromId + "_" + sanitizeId(branch.getId()); + sb.append(" ").append(branchId).append(" --> ").append(joinId).append("\n"); + } + renderTransitionRules(sb, joinId, pn.getTransitionRules(), workflowPrefix); + } + default -> renderTransitionRules(sb, fromId, node.getTransitionRules(), workflowPrefix); } } private void renderTransitionRules( - StringBuilder sb, String fromId, java.util.List rules) { + StringBuilder sb, String fromId, List rules, String workflowPrefix) { for (TransitionRule rule : rules) { if (rule instanceof SuccessTransition success) { - String toId = sanitizeId(success.getTargetNode()); + String toId = resolveTargetId(success.getTargetNode(), workflowPrefix); sb.append(" ").append(fromId).append(" --> ").append(toId).append("\n"); } else if (rule instanceof FailureTransition failure) { - String toId = sanitizeId(failure.getThenTargetNode()); + String toId = resolveTargetId(failure.getThenTargetNode(), workflowPrefix); String label = failure.getRetryCount() > 0 ? "retry " + failure.getRetryCount() @@ -171,8 +399,11 @@ private void renderTransitionRules( .append("\n"); } else if (rule instanceof ScoreTransition score) { for (ScoreCondition cond : score.getConditions()) { - String toId = sanitizeId(cond.getTargetNode()); - String label = "score " + cond.getOperator() + " " + cond.getValue(); + String toId = resolveTargetId(cond.getTargetNode(), workflowPrefix); + String label = + cond.getOperator() == ComparisonOperator.RANGE && cond.range() != null + ? "score " + cond.range().start() + "–" + cond.range().end() + : "score " + cond.getOperator() + " " + cond.getValue(); sb.append(" ") .append(fromId) .append(" -->|") @@ -181,11 +412,28 @@ private void renderTransitionRules( .append(toId) .append("\n"); } + } else if (rule instanceof ApprovalTransition(boolean expected, String targetNode)) { + String toId = resolveTargetId(targetNode, workflowPrefix); + String label = expected ? "approved" : "rejected"; + sb.append(" ") + .append(fromId) + .append(" -->|") + .append(label) + .append("| ") + .append(toId) + .append("\n"); } } } - private String sanitizeId(String id) { + private static String resolveTargetId(String nodeId, String workflowPrefix) { + if (workflowPrefix != null) { + return namespacedId(workflowPrefix, nodeId); + } + return sanitizeId(nodeId); + } + + private static String sanitizeId(String id) { String sanitized = id.replaceAll("[^a-zA-Z0-9_]", "_"); // Prefix reserved Mermaid keywords if (isReservedKeyword(sanitized)) { @@ -194,7 +442,11 @@ private String sanitizeId(String id) { return sanitized; } - private boolean isReservedKeyword(String id) { + private static String escapeLabel(String label) { + return label.replace("\"", """); + } + + private static boolean isReservedKeyword(String id) { return switch (id.toLowerCase()) { case "end", "subgraph", diff --git a/hensu-cli/src/main/java/io/hensu/cli/visualizer/TextVisualizationFormat.java b/hensu-cli/src/main/java/io/hensu/cli/visualizer/TextVisualizationFormat.java index e3f7ca5..36b1eca 100644 --- a/hensu-cli/src/main/java/io/hensu/cli/visualizer/TextVisualizationFormat.java +++ b/hensu-cli/src/main/java/io/hensu/cli/visualizer/TextVisualizationFormat.java @@ -11,6 +11,8 @@ import java.util.ArrayDeque; import java.util.Deque; import java.util.HashSet; +import java.util.List; +import java.util.Map; import java.util.Set; /// ASCII text visualization format for workflows with ANSI color support. @@ -29,6 +31,8 @@ @ApplicationScoped public class TextVisualizationFormat implements VisualizationFormat { + private static final int SUB_WORKFLOW_BORDER_WIDTH = 46; + @Override public String getName() { return "text"; @@ -39,6 +43,11 @@ public String render(Workflow workflow) { return render(workflow, true); } + @Override + public String render(Workflow workflow, Map subWorkflows) { + return render(workflow, subWorkflows, true); + } + /// Renders workflow graph with configurable color support. /// /// Performs a breadth-first traversal from the start node, rendering each node @@ -48,6 +57,16 @@ public String render(Workflow workflow) { /// @param useColor whether to apply ANSI color codes /// @return formatted text representation, never null public String render(Workflow workflow, boolean useColor) { + return render(workflow, Map.of(), useColor); + } + + /// Renders workflow graph with sub-workflows and configurable color support. + /// + /// @param workflow the workflow to visualize, not null + /// @param subWorkflows loaded sub-workflows keyed by workflow ID, not null + /// @param useColor whether to apply ANSI color codes + /// @return formatted text representation, never null + public String render(Workflow workflow, Map subWorkflows, boolean useColor) { AnsiStyles styles = AnsiStyles.of(useColor); StringBuilder sb = new StringBuilder(); sb.append( @@ -57,9 +76,31 @@ public String render(Workflow workflow, boolean useColor) { sb.append(styles.gray("─".repeat(50))).append(System.lineSeparator()); sb.append(System.lineSeparator()); + renderWorkflowNodes(sb, workflow, subWorkflows, 0, styles); + + return sb.toString(); + } + + /// Renders workflow graph with configurable color support (public + /// for VerboseExecutionListener). + /// + /// @param node the node to render, not null + /// @param nodeId the node identifier for display, not null + /// @param useColor whether to apply ANSI color codes + /// @return formatted node box, never null + public String renderNode(Node node, String nodeId, boolean useColor) { + return renderNode(node, nodeId, "", AnsiStyles.of(useColor)); + } + + private void renderWorkflowNodes( + StringBuilder sb, + Workflow workflow, + Map subWorkflows, + int baseLevel, + AnsiStyles styles) { Set visited = new HashSet<>(); Deque queue = new ArrayDeque<>(); - queue.add(new NodeLevel(workflow.getStartNode(), 0)); + queue.add(new NodeLevel(workflow.getStartNode(), baseLevel)); while (!queue.isEmpty()) { NodeLevel current = queue.removeFirst(); @@ -69,31 +110,62 @@ public String render(Workflow workflow, boolean useColor) { if (visited.contains(nodeId)) continue; visited.add(nodeId); - String indent = " ".repeat(level); Node node = workflow.getNodes().get(nodeId); if (node == null) continue; + String indent = " ".repeat(level); sb.append(renderNode(node, nodeId, indent, styles)); sb.append(System.lineSeparator()); + if (node instanceof SubWorkflowNode swn) { + Workflow sub = subWorkflows.get(swn.getWorkflowId()); + if (sub != null) { + renderSubWorkflowBlock(sb, sub, subWorkflows, level + 1, styles); + sb.append(System.lineSeparator()); + } + } + collectNextNodes(node, level, queue); } - - return sb.toString(); } - /// Renders a single workflow node as a styled box. - /// - /// Used by {@link io.hensu.cli.execution.VerboseExecutionListener} - /// to display node details during execution. - /// Output includes node type, agent/executor info, and transition rules. - /// - /// @param node the node to render, not null - /// @param nodeId the node identifier for display, not null - /// @param useColor whether to apply ANSI color codes - /// @return formatted node box, never null - public String renderNode(Node node, String nodeId, boolean useColor) { - return renderNode(node, nodeId, "", AnsiStyles.of(useColor)); + private void renderSubWorkflowBlock( + StringBuilder sb, + Workflow subWorkflow, + Map subWorkflows, + int level, + AnsiStyles styles) { + String indent = " ".repeat(level); + + // Render sub-workflow content into a buffer + StringBuilder content = new StringBuilder(); + renderWorkflowNodes(content, subWorkflow, subWorkflows, 0, styles); + String contentStr = content.toString().stripTrailing(); + + // Top border with label + String label = styles.gray(subWorkflow.getMetadata().getName()); + sb.append(indent) + .append(styles.boxTopWithLabel(label, SUB_WORKFLOW_BORDER_WIDTH)) + .append(System.lineSeparator()); + sb.append(indent).append(styles.boxMid()).append(System.lineSeparator()); + + // Prefix each content line with border + for (String line : contentStr.split("\n", -1)) { + if (line.trim().isEmpty()) { + sb.append(indent).append(styles.boxMid()).append(System.lineSeparator()); + } else { + sb.append(indent) + .append(styles.boxMid()) + .append(" ") + .append(line) + .append(System.lineSeparator()); + } + } + + // Bottom border + sb.append(indent) + .append(styles.separatorBottom(SUB_WORKFLOW_BORDER_WIDTH)) + .append(System.lineSeparator()); } private String renderNode(Node node, String nodeId, String indent, AnsiStyles styles) { @@ -129,6 +201,15 @@ private String renderNode(Node node, String nodeId, String indent, AnsiStyles st "rubric", standardNode.getRubric().getCriteria().size() + " criteria")); } + if (standardNode.hasPlanningEnabled()) { + sb.append( + String.format( + "%s%s %-9s %s%n", + indent, + styles.boxMid(), + "planning", + standardNode.getPlanningConfig().mode())); + } if (standardNode.getReviewConfig() != null) { sb.append( String.format( @@ -305,6 +386,22 @@ private String renderNode(Node node, String nodeId, String indent, AnsiStyles st } appendTransitions(sb, indent, actionNode.getTransitionRules(), styles); } + case SubWorkflowNode swn -> { + sb.append( + String.format( + "%s%s %-9s %s%n", + indent, + styles.boxMid(), + "workflow", + styles.bold(swn.getWorkflowId()))); + if (swn.getTargetVersion() != null) { + sb.append( + String.format( + "%s%s %-9s %s%n", + indent, styles.boxMid(), "version", swn.getTargetVersion())); + } + appendTransitions(sb, indent, swn.getTransitionRules(), styles); + } case EndNode endNode -> { boolean isSuccess = endNode.getExitStatus().toString().equals("SUCCESS"); sb.append( @@ -365,11 +462,20 @@ private void appendTransitions( styles.bold(cond.getTargetNode()), styles.dim("score " + cond.getOperator() + " " + condValue))); } + } else if (rule instanceof ApprovalTransition(boolean expected, String targetNode)) { + String label = expected ? "on approval" : "on rejection"; + sb.append( + String.format( + "%s%s %s %-14s %s%n", + indent, + styles.boxMid(), + styles.arrow(), + styles.bold(targetNode), + styles.dim(label))); } } } - /// Colors text based on node type using semantic color methods. private String colorByNodeType(String text, NodeType type, AnsiStyles styles) { return switch (type) { case STANDARD, GENERIC, PARALLEL, FORK, JOIN -> styles.accent(text); @@ -381,19 +487,6 @@ private String colorByNodeType(String text, NodeType type, AnsiStyles styles) { private void collectNextNodes(Node node, int level, Deque queue) { switch (node) { - case StandardNode standardNode -> { - for (TransitionRule rule : standardNode.getTransitionRules()) { - if (rule instanceof SuccessTransition success) { - queue.add(new NodeLevel(success.getTargetNode(), level + 1)); - } else if (rule instanceof FailureTransition failure) { - queue.add(new NodeLevel(failure.getThenTargetNode(), level + 1)); - } else if (rule instanceof ScoreTransition score) { - for (ScoreCondition cond : score.getConditions()) { - queue.add(new NodeLevel(cond.getTargetNode(), level + 1)); - } - } - } - } case LoopNode loopNode -> { if (loopNode.getBreakRules() != null) { for (BreakRule rule : loopNode.getBreakRules()) { @@ -405,44 +498,26 @@ private void collectNextNodes(Node node, int level, Deque queue) { for (String target : forkNode.getTargets()) { queue.add(new NodeLevel(target, level + 1)); } - for (TransitionRule rule : forkNode.getTransitionRules()) { - if (rule instanceof SuccessTransition success) { - queue.add(new NodeLevel(success.getTargetNode(), level + 1)); - } - } - } - case JoinNode joinNode -> { - for (TransitionRule rule : joinNode.getTransitionRules()) { - if (rule instanceof SuccessTransition success) { - queue.add(new NodeLevel(success.getTargetNode(), level + 1)); - } else if (rule instanceof FailureTransition failure) { - queue.add(new NodeLevel(failure.getThenTargetNode(), level + 1)); - } - } - } - case GenericNode genericNode -> { - for (TransitionRule rule : genericNode.getTransitionRules()) { - if (rule instanceof SuccessTransition success) { - queue.add(new NodeLevel(success.getTargetNode(), level + 1)); - } else if (rule instanceof FailureTransition failure) { - queue.add(new NodeLevel(failure.getThenTargetNode(), level + 1)); - } else if (rule instanceof ScoreTransition score) { - for (ScoreCondition cond : score.getConditions()) { - queue.add(new NodeLevel(cond.getTargetNode(), level + 1)); - } - } - } + collectTransitionTargets(forkNode.getTransitionRules(), level, queue); } - case ActionNode actionNode -> { - for (TransitionRule rule : actionNode.getTransitionRules()) { - if (rule instanceof SuccessTransition success) { - queue.add(new NodeLevel(success.getTargetNode(), level + 1)); - } else if (rule instanceof FailureTransition failure) { - queue.add(new NodeLevel(failure.getThenTargetNode(), level + 1)); - } + default -> collectTransitionTargets(node.getTransitionRules(), level, queue); + } + } + + private void collectTransitionTargets( + List rules, int level, Deque queue) { + for (TransitionRule rule : rules) { + if (rule instanceof SuccessTransition success) { + queue.add(new NodeLevel(success.getTargetNode(), level + 1)); + } else if (rule instanceof FailureTransition failure) { + queue.add(new NodeLevel(failure.getThenTargetNode(), level + 1)); + } else if (rule instanceof ScoreTransition score) { + for (ScoreCondition cond : score.getConditions()) { + queue.add(new NodeLevel(cond.getTargetNode(), level + 1)); } + } else if (rule instanceof ApprovalTransition approval) { + queue.add(new NodeLevel(approval.targetNode(), level + 1)); } - default -> {} } } diff --git a/hensu-cli/src/main/java/io/hensu/cli/visualizer/VisualizationFormat.java b/hensu-cli/src/main/java/io/hensu/cli/visualizer/VisualizationFormat.java index 8593685..b10576a 100644 --- a/hensu-cli/src/main/java/io/hensu/cli/visualizer/VisualizationFormat.java +++ b/hensu-cli/src/main/java/io/hensu/cli/visualizer/VisualizationFormat.java @@ -1,6 +1,7 @@ package io.hensu.cli.visualizer; import io.hensu.core.workflow.Workflow; +import java.util.Map; /// Strategy interface for rendering workflows in different output formats. /// @@ -26,4 +27,16 @@ public interface VisualizationFormat { /// @param workflow the workflow to visualize, not null /// @return formatted string representation, never null String render(Workflow workflow); + + /// Renders the workflow graph with sub-workflows available for inlining. + /// + /// Sub-workflows matching a {@link io.hensu.core.workflow.node.SubWorkflowNode}'s + /// workflow ID are rendered inline within the parent graph. + /// + /// @param workflow the root workflow to visualize, not null + /// @param subWorkflows loaded sub-workflows keyed by workflow ID, not null (may be empty) + /// @return formatted string representation, never null + default String render(Workflow workflow, Map subWorkflows) { + return render(workflow); + } } diff --git a/hensu-cli/src/main/java/io/hensu/cli/visualizer/WorkflowVisualizer.java b/hensu-cli/src/main/java/io/hensu/cli/visualizer/WorkflowVisualizer.java index b154ccc..10103a0 100644 --- a/hensu-cli/src/main/java/io/hensu/cli/visualizer/WorkflowVisualizer.java +++ b/hensu-cli/src/main/java/io/hensu/cli/visualizer/WorkflowVisualizer.java @@ -31,13 +31,15 @@ public WorkflowVisualizer(Instance formatInstances) { .collect(Collectors.toMap(VisualizationFormat::getName, format -> format)); } - /// Renders workflow using the specified format. + /// Renders workflow with sub-workflows available for inlining. /// - /// @param workflow the workflow to visualize, not null - /// @param formatName the format name (e.g., "text", "mermaid"), not null + /// @param workflow the root workflow to visualize, not null + /// @param subWorkflows loaded sub-workflows keyed by workflow ID, not null + /// @param formatName the format name (e.g., "text", "mermaid"), not null /// @return formatted visualization string, never null /// @throws IllegalArgumentException if format is not registered - public String visualize(Workflow workflow, String formatName) { + public String visualize( + Workflow workflow, Map subWorkflows, String formatName) { VisualizationFormat format = formats.get(formatName); if (format == null) { throw new IllegalArgumentException( @@ -46,23 +48,6 @@ public String visualize(Workflow workflow, String formatName) { + ". Available: " + String.join(", ", formats.keySet())); } - return format.render(workflow); - } - - /// Renders workflow using the default text format. - /// - /// Equivalent to calling `visualize(workflow, "text")`. - /// - /// @param workflow the workflow to visualize, not null - /// @return ASCII text visualization with ANSI colors, never null - public String visualize(Workflow workflow) { - return visualize(workflow, "text"); - } - - /// Returns the names of all registered visualization formats. - /// - /// @return iterable of format names (e.g., "text", "mermaid"), never null - public Iterable getAvailableFormats() { - return formats.keySet(); + return format.render(workflow, subWorkflows); } } diff --git a/hensu-cli/src/test/java/io/hensu/cli/commands/WorkflowVisualizeCommandTest.java b/hensu-cli/src/test/java/io/hensu/cli/commands/WorkflowVisualizeCommandTest.java index 7c89134..2bc0236 100644 --- a/hensu-cli/src/test/java/io/hensu/cli/commands/WorkflowVisualizeCommandTest.java +++ b/hensu-cli/src/test/java/io/hensu/cli/commands/WorkflowVisualizeCommandTest.java @@ -11,6 +11,7 @@ import io.hensu.dsl.parsers.KotlinScriptParser; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -65,7 +66,8 @@ void shouldVisualizeWithTextFormat() throws Exception { when(kotlinParser.parse(any(WorkingDirectory.class), eq(workflowName))) .thenReturn(workflow); - when(visualizer.visualize(workflow, "text")).thenReturn(expectedOutput); + when(visualizer.visualize(eq(workflow), any(Map.class), eq("text"))) + .thenReturn(expectedOutput); // When command.run(); @@ -88,7 +90,8 @@ void shouldOutputMermaidFormat() throws Exception { when(kotlinParser.parse(any(WorkingDirectory.class), eq(workflowName))) .thenReturn(workflow); - when(visualizer.visualize(workflow, "mermaid")).thenReturn(mermaidOutput); + when(visualizer.visualize(eq(workflow), any(Map.class), eq("mermaid"))) + .thenReturn(mermaidOutput); // When command.run(); @@ -109,7 +112,7 @@ void shouldHandleUnsupportedFormat() throws Exception { when(kotlinParser.parse(any(WorkingDirectory.class), eq(workflowName))) .thenReturn(workflow); - when(visualizer.visualize(workflow, "unknown")) + when(visualizer.visualize(eq(workflow), any(Map.class), eq("unknown"))) .thenThrow(new IllegalArgumentException("Unsupported format: unknown")); // When @@ -131,7 +134,8 @@ void shouldAcceptKtsExtension() throws Exception { when(kotlinParser.parse(any(WorkingDirectory.class), eq(workflowName))) .thenReturn(workflow); - when(visualizer.visualize(workflow, "text")).thenReturn("Workflow: kts-workflow"); + when(visualizer.visualize(eq(workflow), any(Map.class), eq("text"))) + .thenReturn("Workflow: kts-workflow"); // When command.run(); diff --git a/hensu-cli/src/test/java/io/hensu/cli/visualizer/MermaidVisualizationFormatTest.java b/hensu-cli/src/test/java/io/hensu/cli/visualizer/MermaidVisualizationFormatTest.java index 3e073bd..b225736 100644 --- a/hensu-cli/src/test/java/io/hensu/cli/visualizer/MermaidVisualizationFormatTest.java +++ b/hensu-cli/src/test/java/io/hensu/cli/visualizer/MermaidVisualizationFormatTest.java @@ -3,14 +3,19 @@ import static org.assertj.core.api.Assertions.assertThat; import io.hensu.core.agent.AgentConfig; +import io.hensu.core.execution.parallel.ConsensusStrategy; import io.hensu.core.execution.result.ExitStatus; import io.hensu.core.workflow.Workflow; import io.hensu.core.workflow.WorkflowMetadata; import io.hensu.core.workflow.node.EndNode; +import io.hensu.core.workflow.node.ForkNode; import io.hensu.core.workflow.node.GenericNode; +import io.hensu.core.workflow.node.JoinNode; import io.hensu.core.workflow.node.LoopNode; import io.hensu.core.workflow.node.Node; +import io.hensu.core.workflow.node.ParallelNode; import io.hensu.core.workflow.node.StandardNode; +import io.hensu.core.workflow.node.SubWorkflowNode; import io.hensu.core.workflow.transition.FailureTransition; import io.hensu.core.workflow.transition.SuccessTransition; import java.time.Instant; @@ -29,30 +34,6 @@ void setUp() { format = new MermaidVisualizationFormat(); } - @Test - void shouldReturnMermaidAsFormatName() { - assertThat(format.getName()).isEqualTo("mermaid"); - } - - @Test - void shouldWrapOutputInCodeBlock() { - Workflow workflow = createSimpleWorkflow("test-workflow"); - - String result = format.render(workflow); - - assertThat(result).startsWith("```mermaid\n"); - assertThat(result).endsWith("```\n"); - } - - @Test - void shouldUseFlowchartTopDown() { - Workflow workflow = createSimpleWorkflow("flowchart-workflow"); - - String result = format.render(workflow); - - assertThat(result).contains("flowchart TD"); - } - @Test void shouldRenderSubgraphWithWorkflowName() { Workflow workflow = createSimpleWorkflow("subgraph-workflow"); @@ -64,13 +45,13 @@ void shouldRenderSubgraphWithWorkflowName() { } @Test - void shouldRenderStandardNodeAsRectangle() { + void shouldRenderStandardNodeAsStadium() { Workflow workflow = createSimpleWorkflow("standard-workflow"); String result = format.render(workflow); - // Standard nodes use rectangle syntax: id["label"] - assertThat(result).contains("start[\""); + // Standard nodes use stadium (pill) syntax: id(["label"]) + assertThat(result).contains("start([\""); } @Test @@ -274,6 +255,361 @@ private Workflow createWorkflowWithGenericNode() { .build(); } + @Test + void shouldRenderSubWorkflowNodeAsCylinder() { + Workflow workflow = createWorkflowWithSubWorkflow(); + + String result = format.render(workflow); + + assertThat(result).contains("[(\""); + assertThat(result).contains("sub: sub-summarizer"); + } + + @Test + void shouldRenderSubWorkflowEdges() { + Workflow workflow = createWorkflowWithSubWorkflow(); + + String result = format.render(workflow); + + assertThat(result).contains("delegate --> node_end"); + } + + @Test + void shouldInlineSubWorkflowAsNestedSubgraph() { + Workflow root = createWorkflowWithSubWorkflow(); + Workflow sub = createSubWorkflow(); + + String result = format.render(root, Map.of("sub-summarizer", sub)); + + assertThat(result).contains("subgraph sub_summarizer[\"sub-summarizer\"]"); + assertThat(result).contains("sub_summarizer__sub_start"); + assertThat(result).contains("-->|sub|"); + assertThat(result).contains("sub_summarizer__sub_end"); + assertThat(result).contains("delegate -->|sub| sub_summarizer__sub_start"); + assertThat(result).contains("sub_summarizer__sub_start --> sub_summarizer__sub_end"); + } + + @Test + void shouldNamespaceOverlappingNodeIds() { + Map rootNodes = new HashMap<>(); + rootNodes.put( + "init", + SubWorkflowNode.builder() + .id("init") + .workflowId("child") + .transitionRules(List.of(new SuccessTransition("end"))) + .build()); + rootNodes.put("end", EndNode.builder().id("end").status(ExitStatus.SUCCESS).build()); + + Workflow root = + Workflow.builder() + .id("parent") + .version("1.0.0") + .metadata( + new WorkflowMetadata( + "parent", "Parent", "tester", Instant.now(), List.of())) + .agents(Map.of()) + .nodes(rootNodes) + .startNode("init") + .build(); + + Map childNodes = new HashMap<>(); + childNodes.put( + "init", + StandardNode.builder() + .id("init") + .agentId("agent-1") + .prompt("Work") + .transitionRules(List.of(new SuccessTransition("end"))) + .build()); + childNodes.put("end", EndNode.builder().id("end").status(ExitStatus.SUCCESS).build()); + + Workflow child = + Workflow.builder() + .id("child") + .version("1.0.0") + .metadata( + new WorkflowMetadata( + "child", "Child", "tester", Instant.now(), List.of())) + .agents(Map.of()) + .nodes(childNodes) + .startNode("init") + .build(); + + String result = format.render(root, Map.of("child", child)); + + assertThat(result).contains("child__init"); + assertThat(result).contains("child__node_end"); + assertThat(result).contains("init(["); + assertThat(result).contains("init -->|sub| child__init"); + assertThat(result).contains("child__init --> child__node_end"); + + long initOccurrences = + result.lines() + .filter(l -> l.trim().startsWith("init([") || l.trim().startsWith("init[(")) + .count(); + assertThat(initOccurrences).as("root 'init' node defined exactly once").isEqualTo(1); + } + + @Test + void shouldEmitDarkModeStyleDeclarations() { + Workflow workflow = createSimpleWorkflow("styled-workflow"); + + String result = format.render(workflow); + + assertThat(result).contains("style styled_workflow fill:#2c2c2e, stroke:#3a3a3c"); + assertThat(result).contains("style start fill:#2c2c2e, stroke:#48484a"); + assertThat(result).contains("style node_end fill:#2c2c2e, stroke:#48484a"); + assertThat(result).contains("linkStyle default stroke:#0A84FF, stroke-width:1px"); + } + + @Test + void shouldStyleNestedSubgraphDifferently() { + Workflow root = createWorkflowWithSubWorkflow(); + Workflow sub = createSubWorkflow(); + + String result = format.render(root, Map.of("sub-summarizer", sub)); + + assertThat(result) + .as("nested subgraph uses surface-nested fill") + .contains("style sub_summarizer fill:#3a3a3c, stroke:#48484a"); + assertThat(result) + .as("root subgraph uses surface fill") + .contains("style sub_workflow_test fill:#2c2c2e, stroke:#3a3a3c"); + } + + @Test + void shouldEscapeQuotesInLabels() { + Map nodes = new HashMap<>(); + nodes.put( + "step", + StandardNode.builder() + .id("step") + .agentId("agent-1") + .prompt("Test") + .transitionRules(List.of(new SuccessTransition("end"))) + .build()); + nodes.put("end", EndNode.builder().id("end").status(ExitStatus.SUCCESS).build()); + + Workflow workflow = + Workflow.builder() + .id("quote-test") + .version("1.0.0") + .metadata( + new WorkflowMetadata( + "has \"quotes\"", + "Test", + "tester", + Instant.now(), + List.of())) + .agents(Map.of()) + .nodes(nodes) + .startNode("step") + .build(); + + String result = format.render(workflow); + + assertThat(result) + .as("quotes in workflow name must be escaped") + .contains("has "quotes""); + assertThat(result) + .as("no unescaped quotes breaking Mermaid syntax") + .doesNotContain("[\"has \"quotes\""); + } + + @Test + void shouldRenderForkJoinShapes() { + Map nodes = new HashMap<>(); + nodes.put( + "split", + ForkNode.builder("split") + .targets("branch-a", "branch-b") + .transitionRules(List.of(new SuccessTransition("merge"))) + .build()); + nodes.put( + "branch-a", + StandardNode.builder() + .id("branch-a") + .agentId("worker") + .prompt("A") + .transitionRules(List.of()) + .build()); + nodes.put( + "branch-b", + StandardNode.builder() + .id("branch-b") + .agentId("worker") + .prompt("B") + .transitionRules(List.of()) + .build()); + nodes.put( + "merge", + JoinNode.builder("merge") + .awaitTargets("branch-a", "branch-b") + .transitionRules(List.of(new SuccessTransition("end"))) + .build()); + nodes.put("end", EndNode.builder().id("end").status(ExitStatus.SUCCESS).build()); + + Workflow workflow = + Workflow.builder() + .id("fork-join-test") + .version("1.0.0") + .metadata( + new WorkflowMetadata( + "fork-join-test", + "Test", + "tester", + Instant.now(), + List.of())) + .agents(Map.of()) + .nodes(nodes) + .startNode("split") + .build(); + + String result = format.render(workflow); + + assertThat(result).as("fork uses asymmetric shape").contains("split>\""); + assertThat(result).as("join uses plain rectangle").contains("merge[\""); + assertThat(result).as("fork edge label").contains("-->|fork|"); + assertThat(result).as("join await edge").contains("-.->|await|"); + assertThat(result).as("fork targets rendered").contains("branch_a").contains("branch_b"); + } + + @Test + void shouldDecomposeParallelNodeIntoBranchAndJoinNodes() { + Map nodes = new HashMap<>(); + nodes.put( + "proposals", + ParallelNode.builder("proposals") + .branch("innovative", "proposer1", "Innovate") + .branch("practical", "proposer2", "Practical") + .branch("safe", "proposer3", "Safe") + .consensus("judge", ConsensusStrategy.JUDGE_DECIDES) + .transitionRules( + List.of( + new SuccessTransition("refine"), + new FailureTransition(0, "rejected"))) + .build()); + nodes.put( + "refine", + StandardNode.builder() + .id("refine") + .agentId("refiner") + .prompt("Refine") + .transitionRules(List.of(new SuccessTransition("end"))) + .build()); + nodes.put("rejected", EndNode.builder().id("rejected").status(ExitStatus.FAILURE).build()); + nodes.put("end", EndNode.builder().id("end").status(ExitStatus.SUCCESS).build()); + + Workflow workflow = + Workflow.builder() + .id("parallel-test") + .version("1.0.0") + .metadata( + new WorkflowMetadata( + "parallel-test", + "Test", + "tester", + Instant.now(), + List.of())) + .agents(Map.of()) + .nodes(nodes) + .startNode("proposals") + .build(); + + String result = format.render(workflow); + + assertThat(result) + .as("parallel uses asymmetric shape with branch count") + .contains("proposals>\"proposals\\n(parallel: 3)\"]"); + assertThat(result) + .as("branch nodes rendered as stadiums with agent") + .contains("proposals_innovative([\"innovative\\n[proposer1]\"])") + .contains("proposals_practical([\"practical\\n[proposer2]\"])") + .contains("proposals_safe([\"safe\\n[proposer3]\"])"); + assertThat(result) + .as("synthetic join node with consensus strategy") + .contains("proposals___join[\"proposals\\n(join: JUDGE_DECIDES)\"]"); + assertThat(result) + .as("fan-out edges from parallel to branches") + .contains("proposals -->|branch| proposals_innovative") + .contains("proposals -->|branch| proposals_practical") + .contains("proposals -->|branch| proposals_safe"); + assertThat(result) + .as("branch-to-join edges") + .contains("proposals_innovative --> proposals___join") + .contains("proposals_practical --> proposals___join") + .contains("proposals_safe --> proposals___join"); + assertThat(result) + .as("transitions from join node") + .contains("proposals___join --> refine") + .contains("proposals___join -.->|failure| rejected"); + } + + private Workflow createWorkflowWithSubWorkflow() { + Map nodes = new HashMap<>(); + nodes.put( + "delegate", + SubWorkflowNode.builder() + .id("delegate") + .workflowId("sub-summarizer") + .transitionRules(List.of(new SuccessTransition("end"))) + .build()); + nodes.put("end", EndNode.builder().id("end").status(ExitStatus.SUCCESS).build()); + + return Workflow.builder() + .id("sub-workflow-test") + .version("1.0.0") + .metadata( + new WorkflowMetadata( + "sub-workflow-test", + "Test workflow", + "tester", + Instant.now(), + List.of())) + .agents(Map.of()) + .nodes(nodes) + .startNode("delegate") + .build(); + } + + private Workflow createSubWorkflow() { + Map agents = new HashMap<>(); + agents.put( + "summarizer-agent", + AgentConfig.builder() + .id("summarizer-agent") + .role("Summarizer") + .model("test-model") + .build()); + + Map nodes = new HashMap<>(); + nodes.put( + "sub-start", + StandardNode.builder() + .id("sub-start") + .agentId("summarizer-agent") + .prompt("Summarize") + .transitionRules(List.of(new SuccessTransition("sub-end"))) + .build()); + nodes.put("sub-end", EndNode.builder().id("sub-end").status(ExitStatus.SUCCESS).build()); + + return Workflow.builder() + .id("sub-summarizer") + .version("1.0.0") + .metadata( + new WorkflowMetadata( + "sub-summarizer", + "Sub Summarizer", + "tester", + Instant.now(), + List.of())) + .agents(agents) + .nodes(nodes) + .startNode("sub-start") + .build(); + } + private Workflow createWorkflowWithLoopNode() { Map nodes = new HashMap<>(); nodes.put("loop", new LoopNode("loop")); diff --git a/hensu-cli/src/test/java/io/hensu/cli/visualizer/TextVisualizationFormatTest.java b/hensu-cli/src/test/java/io/hensu/cli/visualizer/TextVisualizationFormatTest.java index 2049c26..fa549b5 100644 --- a/hensu-cli/src/test/java/io/hensu/cli/visualizer/TextVisualizationFormatTest.java +++ b/hensu-cli/src/test/java/io/hensu/cli/visualizer/TextVisualizationFormatTest.java @@ -10,6 +10,7 @@ import io.hensu.core.workflow.node.GenericNode; import io.hensu.core.workflow.node.Node; import io.hensu.core.workflow.node.StandardNode; +import io.hensu.core.workflow.node.SubWorkflowNode; import io.hensu.core.workflow.transition.FailureTransition; import io.hensu.core.workflow.transition.SuccessTransition; import java.time.Instant; @@ -187,6 +188,245 @@ private Workflow createWorkflowWithGenericNode() { .build(); } + @Test + void shouldRenderSubWorkflowNode() { + Workflow workflow = createWorkflowWithSubWorkflow(); + + String result = format.render(workflow, false); + + assertThat(result).contains("delegate"); + assertThat(result).contains("SUB_WORKFLOW"); + assertThat(result).contains("workflow"); + assertThat(result).contains("sub-summarizer"); + assertThat(result).contains("on success"); + } + + @Test + void shouldRenderSubWorkflowNodeWithVersion() { + Map nodes = new HashMap<>(); + nodes.put( + "delegate", + SubWorkflowNode.builder() + .id("delegate") + .workflowId("sub-summarizer") + .targetVersion("2.0.0") + .transitionRules(List.of(new SuccessTransition("end"))) + .build()); + nodes.put("end", EndNode.builder().id("end").status(ExitStatus.SUCCESS).build()); + + Workflow workflow = + Workflow.builder() + .id("version-workflow") + .version("1.0.0") + .metadata( + new WorkflowMetadata( + "version-workflow", + "Test", + "tester", + Instant.now(), + List.of())) + .agents(Map.of()) + .nodes(nodes) + .startNode("delegate") + .build(); + + String result = format.render(workflow, false); + + assertThat(result).contains("version"); + assertThat(result).contains("2.0.0"); + } + + @Test + void shouldInlineSubWorkflowWhenProvided() { + Workflow root = createWorkflowWithSubWorkflow(); + Workflow sub = createSubWorkflow(); + + String result = format.render(root, Map.of("sub-summarizer", sub), false); + + assertThat(result).contains("delegate"); + assertThat(result).contains("SUB_WORKFLOW"); + assertThat(result).contains("sub-start"); + assertThat(result).contains("STANDARD"); + assertThat(result).contains("summarizer-agent"); + + var lines = result.lines().toList(); + int delegateLine = -1; + int subStartLine = -1; + int borderTopLine = -1; + int borderBottomLine = -1; + for (int i = 0; i < lines.size(); i++) { + if (lines.get(i).contains("delegate") && lines.get(i).contains("SUB_WORKFLOW")) { + delegateLine = i; + } + if (lines.get(i).contains("sub-start") && lines.get(i).contains("STANDARD")) { + subStartLine = i; + } + if (lines.get(i).contains("sub-summarizer") && lines.get(i).contains("┌")) { + borderTopLine = i; + } + if (borderTopLine >= 0 + && lines.get(i).contains("└") + && lines.get(i).contains("──────")) { + borderBottomLine = i; + } + } + assertThat(delegateLine) + .as("delegate node must appear before inlined sub-start") + .isGreaterThanOrEqualTo(0); + assertThat(subStartLine) + .as("sub-start must appear after delegate") + .isGreaterThan(delegateLine); + + assertThat(borderTopLine) + .as("bordered subgraph must have a top border with label") + .isGreaterThan(delegateLine); + assertThat(borderBottomLine) + .as("bordered subgraph must have a bottom border") + .isGreaterThan(subStartLine); + assertThat(subStartLine) + .as("sub-start must be inside the bordered subgraph") + .isBetween(borderTopLine, borderBottomLine); + } + + @Test + void shouldHandleOverlappingNodeIds() { + Map rootNodes = new HashMap<>(); + rootNodes.put( + "start", + SubWorkflowNode.builder() + .id("start") + .workflowId("child") + .transitionRules(List.of(new SuccessTransition("end"))) + .build()); + rootNodes.put("end", EndNode.builder().id("end").status(ExitStatus.SUCCESS).build()); + + Workflow root = + Workflow.builder() + .id("parent") + .version("1.0.0") + .metadata( + new WorkflowMetadata( + "parent", "Parent", "tester", Instant.now(), List.of())) + .agents(Map.of()) + .nodes(rootNodes) + .startNode("start") + .build(); + + Map childNodes = new HashMap<>(); + childNodes.put( + "start", + StandardNode.builder() + .id("start") + .agentId("child-agent") + .prompt("Do child work") + .transitionRules(List.of(new SuccessTransition("end"))) + .build()); + childNodes.put("end", EndNode.builder().id("end").status(ExitStatus.SUCCESS).build()); + + Workflow child = + Workflow.builder() + .id("child") + .version("1.0.0") + .metadata( + new WorkflowMetadata( + "child", "Child", "tester", Instant.now(), List.of())) + .agents(Map.of()) + .nodes(childNodes) + .startNode("start") + .build(); + + String result = format.render(root, Map.of("child", child), false); + + assertThat(result).contains("SUB_WORKFLOW"); + assertThat(result).contains("child-agent"); + long endCount = result.lines().filter(line -> line.contains("END")).count(); + assertThat(endCount).as("both parent and child 'end' nodes must render").isEqualTo(2); + + long startAsSubWorkflow = + result.lines() + .filter(l -> l.contains("SUB_WORKFLOW") && l.contains("start")) + .count(); + long startAsStandard = + result.lines().filter(l -> l.contains("STANDARD") && l.contains("start")).count(); + assertThat(startAsSubWorkflow).as("root 'start' renders as SUB_WORKFLOW").isEqualTo(1); + assertThat(startAsStandard).as("child 'start' renders as STANDARD").isEqualTo(1); + + assertThat(result) + .as("child workflow must be inside a bordered subgraph") + .contains("child"); + long borderTopCount = + result.lines() + .filter(l -> l.contains("┌") && l.contains("child") && l.contains("─")) + .count(); + assertThat(borderTopCount) + .as("bordered subgraph top border present") + .isGreaterThanOrEqualTo(1); + } + + private Workflow createWorkflowWithSubWorkflow() { + Map nodes = new HashMap<>(); + nodes.put( + "delegate", + SubWorkflowNode.builder() + .id("delegate") + .workflowId("sub-summarizer") + .transitionRules(List.of(new SuccessTransition("end"))) + .build()); + nodes.put("end", EndNode.builder().id("end").status(ExitStatus.SUCCESS).build()); + + return Workflow.builder() + .id("sub-workflow-test") + .version("1.0.0") + .metadata( + new WorkflowMetadata( + "sub-workflow-test", + "Test workflow", + "tester", + Instant.now(), + List.of())) + .agents(Map.of()) + .nodes(nodes) + .startNode("delegate") + .build(); + } + + private Workflow createSubWorkflow() { + Map agents = new HashMap<>(); + agents.put( + "summarizer-agent", + AgentConfig.builder() + .id("summarizer-agent") + .role("Summarizer") + .model("test-model") + .build()); + + Map nodes = new HashMap<>(); + nodes.put( + "sub-start", + StandardNode.builder() + .id("sub-start") + .agentId("summarizer-agent") + .prompt("Summarize") + .transitionRules(List.of(new SuccessTransition("sub-end"))) + .build()); + nodes.put("sub-end", EndNode.builder().id("sub-end").status(ExitStatus.SUCCESS).build()); + + return Workflow.builder() + .id("sub-summarizer") + .version("1.0.0") + .metadata( + new WorkflowMetadata( + "sub-summarizer", + "Sub Summarizer", + "tester", + Instant.now(), + List.of())) + .agents(agents) + .nodes(nodes) + .startNode("sub-start") + .build(); + } + private Workflow createWorkflowWithFailureTransition() { Map agents = new HashMap<>(); agents.put( diff --git a/hensu-server/src/main/resources/application.properties b/hensu-server/src/main/resources/application.properties index 5d3cf27..0020bde 100644 --- a/hensu-server/src/main/resources/application.properties +++ b/hensu-server/src/main/resources/application.properties @@ -96,6 +96,8 @@ hensu.planning.default-timeout=5m # Set HENSU_DB_USER, HENSU_DB_PASSWORD, HENSU_DB_NAME in .env - see .env.example. # Prod: set HENSU_DB_URL, HENSU_DB_USER, HENSU_DB_PASSWORD as environment variables. quarkus.datasource.db-kind=postgresql +%test.quarkus.datasource.active=false +%test.quarkus.datasource.devservices.enabled=false %dev.quarkus.datasource.devservices.enabled=false %dev.quarkus.datasource.username=${HENSU_DB_USER} %dev.quarkus.datasource.password=${HENSU_DB_PASSWORD}