Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/dsl-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -49,7 +51,9 @@ class WorkflowVisualizeCommand extends WorkflowCommand {
protected void execute() {
try {
Workflow workflow = getWorkflow(workflowName, withNames);
String output = visualizer.visualize(workflow, format);
Map<String, Workflow> 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());
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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";
Expand All @@ -39,6 +43,11 @@ public String render(Workflow workflow) {
return render(workflow, true);
}

@Override
public String render(Workflow workflow, Map<String, Workflow> 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
Expand All @@ -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<String, Workflow> subWorkflows, boolean useColor) {
AnsiStyles styles = AnsiStyles.of(useColor);
StringBuilder sb = new StringBuilder();
sb.append(
Expand All @@ -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<String, Workflow> subWorkflows,
int baseLevel,
AnsiStyles styles) {
Set<String> visited = new HashSet<>();
Deque<NodeLevel> queue = new ArrayDeque<>();
queue.add(new NodeLevel(workflow.getStartNode(), 0));
queue.add(new NodeLevel(workflow.getStartNode(), baseLevel));

while (!queue.isEmpty()) {
NodeLevel current = queue.removeFirst();
Expand All @@ -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<String, Workflow> 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) {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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);
Expand All @@ -381,19 +487,6 @@ private String colorByNodeType(String text, NodeType type, AnsiStyles styles) {

private void collectNextNodes(Node node, int level, Deque<NodeLevel> 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()) {
Expand All @@ -405,44 +498,26 @@ private void collectNextNodes(Node node, int level, Deque<NodeLevel> 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<TransitionRule> rules, int level, Deque<NodeLevel> 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 -> {}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
///
Expand All @@ -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<String, Workflow> subWorkflows) {
return render(workflow);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,15 @@ public WorkflowVisualizer(Instance<VisualizationFormat> 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<String, Workflow> subWorkflows, String formatName) {
VisualizationFormat format = formats.get(formatName);
if (format == null) {
throw new IllegalArgumentException(
Expand All @@ -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<String> getAvailableFormats() {
return formats.keySet();
return format.render(workflow, subWorkflows);
}
}
Loading
Loading