Add SwitchBlock with multi-branch routing

This commit is contained in:
Lucio Lelii 2026-03-25 16:52:19 +01:00
parent d14fc6862d
commit 99472ffec4
15 changed files with 833 additions and 32 deletions

View File

@ -296,6 +296,15 @@ public class FlowAssistantService {
config.remove("llmDescriptor");
}
}
case "SwitchBlockConfiguration" -> {
boolean useLlm = inferConditionalUseLlm(config);
config.put("useLlm", useLlm);
if (useLlm) {
config.set("llmDescriptor", llmDescriptorNode(model));
} else {
config.remove("llmDescriptor");
}
}
default -> {
}
}

View File

@ -1,8 +1,10 @@
package it.cnr.isti.workflow.manager.auth.model;
import com.fasterxml.jackson.annotation.JsonAlias;
public class ChangePasswordRequest {
private String username;
@JsonAlias("currentPassword")
private String oldPassword;
private String newPassword;

View File

@ -11,7 +11,12 @@ import static it.cnr.isti.workflow.manager.auth.services.PasswordHasher.*;
@Service
public class AuthService {
public enum ChangePasswordResult {
SUCCESS,
USER_NOT_FOUND,
INVALID_OLD_PASSWORD,
INVALID_NEW_PASSWORD
}
@Autowired
AuthRepository authRepository;
@ -34,17 +39,45 @@ public class AuthService {
return true;
}
public boolean changePassword(String username, String oldPassword, String newPassword) {
return authRepository.findById(username)
.filter(user -> checkPassword(oldPassword, user.getPassword()))
.map(user -> {
user.setPassword(hashPassword(newPassword));
authRepository.save(user);
return true;
})
.orElse(false);
public ChangePasswordResult changePassword(String username, String oldPassword, String newPassword) {
LoginEntity user = authRepository.findById(username).orElse(null);
if (user == null) {
return ChangePasswordResult.USER_NOT_FOUND;
}
if (!checkPassword(oldPassword, user.getPassword())) {
return ChangePasswordResult.INVALID_OLD_PASSWORD;
}
if (!isValidPassword(newPassword)) {
return ChangePasswordResult.INVALID_NEW_PASSWORD;
}
user.setPassword(hashPassword(newPassword));
authRepository.save(user);
return ChangePasswordResult.SUCCESS;
}
private boolean isValidPassword(String password) {
if (password == null || password.length() < 8) {
return false;
}
boolean hasUppercase = false;
boolean hasLowercase = false;
boolean hasDigit = false;
boolean hasSpecial = false;
for (char c : password.toCharArray()) {
if (Character.isWhitespace(c)) {
return false;
}
if (Character.isUpperCase(c)) {
hasUppercase = true;
} else if (Character.isLowerCase(c)) {
hasLowercase = true;
} else if (Character.isDigit(c)) {
hasDigit = true;
} else {
hasSpecial = true;
}
}
return hasUppercase && hasLowercase && hasDigit && hasSpecial;
}
}

View File

@ -0,0 +1,143 @@
package it.cnr.isti.workflow.manager.blocks.configurations;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import it.cnr.isti.workflow.manager.blocks.types.SwitchBlockType;
import it.cnr.isti.workflow.manager.configurations.annotations.LongText;
import it.cnr.isti.workflow.manager.configurations.annotations.Structural;
import it.cnr.isti.workflow.manager.configurations.annotations.UiEnabledWhen;
import it.cnr.isti.workflow.manager.configurations.annotations.UiRequiredWhen;
import it.cnr.isti.workflow.manager.configurations.annotations.UiUniqueItemsBy;
import it.cnr.isti.workflow.manager.llms.LLMDescriptor;
import jakarta.validation.Valid;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Size;
import lombok.Builder;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.NonNull;
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
@Getter
@EqualsAndHashCode(callSuper = true)
public class SwitchBlockConfiguration extends BlockConfiguration<SwitchBlockType> {
@Structural
@UiUniqueItemsBy("name")
@Size(max = 10)
@Valid
@JsonProperty(required = false)
private List<SwitchCase> cases = List.of();
@UiEnabledWhen(field = "useLlm", equals = "false")
@Structural
@LongText(
placeholder = "Add a deterministic selector expression that resolves to one output name, for example: 'approved' or ${{status}}",
tip = "Supports SpEL. The expression must resolve to one of the configured case names. Inputs are derived automatically from placeholders ${{}}.",
acceptVariableAsPlaceholder = true)
private String condition;
@JsonProperty(required = true)
@Structural
private boolean useLlm;
@Valid
@UiEnabledWhen(field = "useLlm", equals = "true", group = "llm")
@UiRequiredWhen(field = "useLlm", equals = "true")
private LLMDescriptor llmDescriptor;
@UiEnabledWhen(field = "useLlm", equals = "true", group = "llm")
@UiRequiredWhen(field = "useLlm", equals = "true")
@Structural
@LongText(
placeholder = "Add the prompt the LLM should use to choose one output name",
tip = "The LLM must choose exactly one configured case name. Inputs are derived automatically from placeholders.",
acceptVariableAsPlaceholder = true)
private String prompt;
@Structural
@LongText(
placeholder = "Define the output payload to propagate on the selected branch",
tip = "The selected branch receives the rendered output template.",
acceptVariableAsPlaceholder = true)
@JsonProperty(required = true)
private String outputTemplate;
@Builder
public SwitchBlockConfiguration(@NonNull String name,
List<SwitchCase> cases,
String condition,
boolean useLlm,
LLMDescriptor llmDescriptor,
String prompt,
String outputTemplate) {
super(name);
this.cases = cases == null ? List.of() : List.copyOf(cases);
this.condition = condition;
this.useLlm = useLlm;
this.llmDescriptor = llmDescriptor;
this.prompt = prompt;
this.outputTemplate = outputTemplate;
}
@Override
public Class<SwitchBlockType> getBlockType() {
return SwitchBlockType.class;
}
public static SwitchBlockConfiguration empty() {
SwitchBlockConfiguration configuration = new SwitchBlockConfiguration();
configuration.name = SwitchBlockType.TYPE;
configuration.cases = List.of();
configuration.condition = "";
configuration.useLlm = false;
configuration.outputTemplate = "";
return configuration;
}
@AssertTrue(message = "condition is required when useLlm is false")
@JsonIgnore
boolean isDeterministicConfigurationValid() {
return useLlm || (condition != null && !condition.isBlank());
}
@AssertTrue(message = "llmDescriptor is required when useLlm is true")
@JsonIgnore
boolean isLlmConfigurationValid() {
return !useLlm || llmDescriptor != null;
}
@AssertTrue(message = "prompt is required when useLlm is true")
@JsonIgnore
boolean isLlmPromptConfigurationValid() {
return !useLlm || (prompt != null && !prompt.isBlank());
}
@AssertTrue(message = "outputTemplate is required")
@JsonIgnore
boolean isOutputTemplateValid() {
return outputTemplate != null && !outputTemplate.isBlank();
}
@AssertTrue(message = "cases must contain unique non-blank names")
@JsonIgnore
boolean areCasesUnique() {
if (cases == null || cases.isEmpty()) {
return true;
}
long distinct = cases.stream()
.map(SwitchCase::name)
.filter(name -> name != null && !name.isBlank())
.distinct()
.count();
long nonBlank = cases.stream()
.map(SwitchCase::name)
.filter(name -> name != null && !name.isBlank())
.count();
return distinct == nonBlank;
}
}

View File

@ -0,0 +1,10 @@
package it.cnr.isti.workflow.manager.blocks.configurations;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record SwitchCase(
@NotBlank
@Size(max = 64)
String name) {
}

View File

@ -0,0 +1,96 @@
package it.cnr.isti.workflow.manager.blocks.factories;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import it.cnr.isti.workflow.manager.blocks.Block;
import it.cnr.isti.workflow.manager.blocks.IOCapability;
import it.cnr.isti.workflow.manager.blocks.IOCapabilityType;
import it.cnr.isti.workflow.manager.blocks.configurations.SwitchBlockConfiguration;
import it.cnr.isti.workflow.manager.blocks.configurations.SwitchCase;
import it.cnr.isti.workflow.manager.blocks.types.SwitchBlockType;
import it.cnr.isti.workflow.manager.ios.IODescriptor;
import it.cnr.isti.workflow.manager.ios.IOType;
@Component
public class SwitchBlockFactory implements BlockFactory<SwitchBlockType, SwitchBlockConfiguration> {
private static final List<IOCapability> INPUT_CAPABILITIES = List.of(
new IOCapability(IOCapabilityType.TEXT, false),
new IOCapability(IOCapabilityType.TEXT, true));
private static final List<IOCapability> OUTPUT_CAPABILITIES = List.of(new IOCapability(IOCapabilityType.TEXT, false));
private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\$\\{\\{(.*?)}}");
private static final Pattern SPEL_VARIABLE_PATTERN = Pattern.compile("#([a-zA-Z_][a-zA-Z0-9_]*)");
@Autowired
private SwitchBlockType blockType;
@Override
public Block<SwitchBlockType> create(SwitchBlockConfiguration configuration) {
List<IODescriptor> inputs = retrieveInputs(configuration).stream()
.map(inputName -> IODescriptor.input(inputName, IOType.TEXT, false, INPUT_CAPABILITIES))
.toList();
Block.BlockBuilder<SwitchBlockType> builder = Block.<SwitchBlockType>builder()
.inputs(inputs)
.specificConfiguration(configuration)
.type(blockType);
for (SwitchCase outputCase : configuration.getCases() == null ? List.<SwitchCase>of() : configuration.getCases()) {
if (outputCase != null && outputCase.name() != null && !outputCase.name().isBlank()) {
builder.output(IODescriptor.output(outputCase.name(), IOType.TEXT, false, OUTPUT_CAPABILITIES));
}
}
return builder.build();
}
@Override
public Block<SwitchBlockType> createEmpty() {
return create(SwitchBlockConfiguration.empty());
}
@Override
public Class<SwitchBlockType> getBlockType() {
return SwitchBlockType.class;
}
private List<String> retrieveInputs(SwitchBlockConfiguration configuration) {
Set<String> inputNames = new LinkedHashSet<>();
if (configuration.isUseLlm()) {
collectMatches(inputNames, configuration.getPrompt(), PLACEHOLDER_PATTERN, 1);
} else {
collectMatches(inputNames, configuration.getCondition(), PLACEHOLDER_PATTERN, 1);
collectMatches(inputNames, configuration.getCondition(), SPEL_VARIABLE_PATTERN, 1);
}
collectMatches(inputNames, configuration.getOutputTemplate(), PLACEHOLDER_PATTERN, 1);
return List.copyOf(inputNames);
}
private void collectMatches(Set<String> target, String source, Pattern pattern, int group) {
if (source == null || source.isBlank()) {
return;
}
Matcher matcher = pattern.matcher(source);
while (matcher.find()) {
String placeholder = matcher.group(group);
if (!PlaceholderInputs.isRuntimeExecutionVariable(placeholder)) {
target.add(placeholder);
}
}
}
@Override
public List<IOCapability> supportedInputCapabilities() {
return INPUT_CAPABILITIES;
}
@Override
public List<IOCapability> supportedOutputCapabilities() {
return OUTPUT_CAPABILITIES;
}
}

View File

@ -0,0 +1,37 @@
package it.cnr.isti.workflow.manager.blocks.types;
import org.springframework.stereotype.Component;
import it.cnr.isti.workflow.manager.blocks.configurations.BlockConfiguration;
import it.cnr.isti.workflow.manager.blocks.configurations.SwitchBlockConfiguration;
@Component(SwitchBlockType.TYPE)
public class SwitchBlockType implements BlockType {
public static final String TYPE = "SwitchBlock";
@Override
public String getName() {
return TYPE;
}
@Override
public String getDescription() {
return "A switch block that routes the workflow to one user-defined output";
}
@Override
public boolean validate() {
return true;
}
@Override
public boolean isUserInteractive() {
return false;
}
@Override
public Class<? extends BlockConfiguration<?>> getBlockConfigurationClass() {
return SwitchBlockConfiguration.class;
}
}

View File

@ -12,6 +12,7 @@ import java.util.Collections;
import org.slf4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.web.bind.annotation.PostMapping;
@ -68,9 +69,31 @@ public class AuthController {
@PostMapping("/change-password")
@Operation(summary = "Change password", description = "Changes the user password after validating the current password.")
public ResponseEntity<?> changePassword(@RequestBody ChangePasswordRequest request) {
if (authService.changePassword(request.getUsername(), request.getOldPassword(), request.getNewPassword())) {
return ResponseEntity.ok().build();
if (request == null
|| isBlank(request.getUsername())
|| isBlank(request.getOldPassword())
|| isBlank(request.getNewPassword())) {
return ResponseEntity.badRequest()
.body("Username, oldPassword/currentPassword and newPassword are required");
}
return ResponseEntity.badRequest().body("Change password failed");
AuthService.ChangePasswordResult result = authService.changePassword(
request.getUsername(),
request.getOldPassword(),
request.getNewPassword());
return switch (result) {
case SUCCESS -> ResponseEntity.ok().build();
case USER_NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND)
.body("User " + request.getUsername() + " not found");
case INVALID_OLD_PASSWORD -> ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body("Current password is invalid");
case INVALID_NEW_PASSWORD -> ResponseEntity.badRequest()
.body("INVALID_NEW_PASSWORD");
};
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
}

View File

@ -18,6 +18,7 @@ import it.cnr.isti.workflow.manager.blocks.configurations.HTTPServerCallBlockCon
import it.cnr.isti.workflow.manager.blocks.configurations.HumanInteractiveBlockConfiguration;
import it.cnr.isti.workflow.manager.blocks.configurations.LLMBlockConfiguration;
import it.cnr.isti.workflow.manager.blocks.configurations.MCPAgentChatBlockConfiguration;
import it.cnr.isti.workflow.manager.blocks.configurations.SwitchBlockConfiguration;
import it.cnr.isti.workflow.manager.containers.Container;
import it.cnr.isti.workflow.manager.containers.configurations.ContainerConfiguration;
import it.cnr.isti.workflow.manager.executions.persistence.ExecutionSnapshot;
@ -265,6 +266,10 @@ public class ExecutionsService {
&& conditionalConfiguration.isUseLlm()) {
return listOfDescriptors(conditionalConfiguration.getLlmDescriptor());
}
if (configuration instanceof SwitchBlockConfiguration switchConfiguration
&& switchConfiguration.isUseLlm()) {
return listOfDescriptors(switchConfiguration.getLlmDescriptor());
}
return List.of();
}

View File

@ -0,0 +1,178 @@
package it.cnr.isti.workflow.manager.executions.executors.blocks;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import it.cnr.isti.workflow.manager.blocks.Block;
import it.cnr.isti.workflow.manager.blocks.configurations.SwitchBlockConfiguration;
import it.cnr.isti.workflow.manager.blocks.configurations.SwitchCase;
import it.cnr.isti.workflow.manager.blocks.types.SwitchBlockType;
import it.cnr.isti.workflow.manager.executions.ExecutionEventLogger;
import it.cnr.isti.workflow.manager.executions.ExecutionTemplateResolver;
import it.cnr.isti.workflow.manager.executions.ExecutionVariableDescriptor;
import it.cnr.isti.workflow.manager.executions.steps.Input;
import it.cnr.isti.workflow.manager.llms.LLMDescriptor;
import it.cnr.isti.workflow.manager.llms.providers.LLMProvider;
@Component
public class SwitchExecutor implements BlockExecutor<SwitchBlockType> {
private static final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(SwitchExecutor.class);
private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\$\\{\\{(.*?)}}");
private static final String LLM_SYSTEM_PROMPT = """
You are a workflow switch evaluator.
Choose exactly one output name from the allowed outputs.
Reply strictly as JSON in the form {"result":"outputName"} or just the output name.
Do not add any extra text.
""";
private final ExpressionParser expressionParser = new SpelExpressionParser();
private final ObjectMapper objectMapper = new ObjectMapper();
@Autowired
private Map<String, LLMProvider> llmProviders;
@Override
public Map<String, Object> execute(Block<SwitchBlockType> block, List<Input> inputs,
Map<String, Object> authorizations, Map<String, Object> executionVariables,
Map<String, ExecutionVariableDescriptor> executionVariableDescriptors, ExecutionEventLogger eventLogger) {
SwitchBlockConfiguration config = (SwitchBlockConfiguration) block.getSpecificConfiguration();
Map<String, Object> inputValues = collectInputValues(inputs);
Set<String> allowedOutputs = config.getCases().stream()
.map(SwitchCase::name)
.filter(StringUtils::hasText)
.collect(java.util.stream.Collectors.toCollection(java.util.LinkedHashSet::new));
String selectedOutput = config.isUseLlm()
? evaluateWithLlm(config, inputValues, authorizations, executionVariables, allowedOutputs)
: evaluateWithExpression(config.getCondition(), inputValues, executionVariables, allowedOutputs);
String payload = resolvePlaceholders(config.getOutputTemplate(), inputValues, executionVariables);
return Map.of(selectedOutput, payload);
}
@Override
public Class<SwitchBlockType> getBlockType() {
return SwitchBlockType.class;
}
private Map<String, Object> collectInputValues(List<Input> inputs) {
Map<String, Object> values = new LinkedHashMap<>();
for (Input input : inputs) {
values.put(input.getDescriptor().getName(), input.getValue());
}
return values;
}
private String evaluateWithExpression(String expression, Map<String, Object> inputValues,
Map<String, Object> executionVariables, Set<String> allowedOutputs) {
String normalizedExpression = normalizeExpression(expression);
StandardEvaluationContext context = new StandardEvaluationContext(inputValues);
inputValues.forEach(context::setVariable);
context.setVariable("vars", executionVariables == null ? Map.of() : executionVariables);
Object result = expressionParser.parseExpression(normalizedExpression).getValue(context);
if (result == null) {
throw new IllegalArgumentException("Switch expression did not resolve to any output");
}
return validateSelectedOutput(result.toString(), allowedOutputs);
}
private String evaluateWithLlm(SwitchBlockConfiguration config, Map<String, Object> inputValues,
Map<String, Object> authorizations, Map<String, Object> executionVariables, Set<String> allowedOutputs) {
LLMDescriptor llmDescriptor = config.getLlmDescriptor();
LLMProvider llmProvider = resolveProvider(llmDescriptor.provider());
String authKey = llmProvider.authorizationKey();
if (llmProvider.requiresAuthorization()
&& (!authorizations.containsKey(authKey) || !StringUtils.hasText(authorizations.get(authKey).toString()))) {
throw new IllegalArgumentException("Missing authorization for provider: " + llmDescriptor.provider());
}
String prompt = buildLlmPrompt(config, inputValues, executionVariables, allowedOutputs);
String response = llmProvider.requiresAuthorization()
? llmProvider.generate(llmDescriptor.model(), prompt, authorizations.get(authKey).toString())
: llmProvider.generate(llmDescriptor.model(), prompt);
return parseSelectedOutput(response, allowedOutputs);
}
private LLMProvider resolveProvider(String providerName) {
LLMProvider llmProvider = llmProviders.get(providerName);
if (llmProvider != null) {
return llmProvider;
}
return llmProviders.values().stream()
.filter(provider -> provider.getName().equals(providerName))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Provider not found: " + providerName));
}
private String buildLlmPrompt(SwitchBlockConfiguration config, Map<String, Object> inputValues,
Map<String, Object> executionVariables, Set<String> allowedOutputs) {
StringBuilder builder = new StringBuilder();
builder.append(LLM_SYSTEM_PROMPT).append("\n");
builder.append("Allowed outputs: ").append(allowedOutputs).append("\n");
builder.append("Decision prompt: ").append(resolvePlaceholders(config.getPrompt(), inputValues, executionVariables)).append("\n");
builder.append("Inputs: ").append(inputValues).append("\n");
return builder.toString();
}
private String normalizeExpression(String expression) {
Matcher matcher = PLACEHOLDER_PATTERN.matcher(expression);
StringBuffer buffer = new StringBuffer();
while (matcher.find()) {
matcher.appendReplacement(buffer, Matcher.quoteReplacement("#" + matcher.group(1)));
}
matcher.appendTail(buffer);
return buffer.toString();
}
private String resolvePlaceholders(String template, Map<String, Object> inputValues,
Map<String, Object> executionVariables) {
return ExecutionTemplateResolver.resolve(template, inputValues, executionVariables);
}
private String parseSelectedOutput(String response, Set<String> allowedOutputs) {
if (!StringUtils.hasText(response)) {
throw new IllegalArgumentException("Empty response from LLM switch evaluator");
}
String trimmed = response.trim();
if (allowedOutputs.contains(trimmed)) {
return trimmed;
}
try {
Map<String, Object> payload = objectMapper.readValue(trimmed, new TypeReference<Map<String, Object>>() {
});
Object result = payload.get("result");
if (result != null) {
return validateSelectedOutput(result.toString(), allowedOutputs);
}
} catch (Exception e) {
logger.debug("Unable to parse switch LLM response as JSON: {}", trimmed, e);
}
throw new IllegalArgumentException("Switch LLM response must contain one configured output name");
}
private String validateSelectedOutput(String selectedOutput, Set<String> allowedOutputs) {
if (!StringUtils.hasText(selectedOutput)) {
throw new IllegalArgumentException("Switch evaluator selected a blank output");
}
if (!allowedOutputs.contains(selectedOutput)) {
throw new IllegalArgumentException("Switch evaluator selected unsupported output: " + selectedOutput);
}
return selectedOutput;
}
}

View File

@ -1,6 +1,7 @@
package it.cnr.isti.workflow.manager.flows.validation;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
@ -14,7 +15,9 @@ import it.cnr.isti.workflow.manager.blocks.Block;
import it.cnr.isti.workflow.manager.blocks.configurations.BlockConfiguration;
import it.cnr.isti.workflow.manager.blocks.factories.BlockFactory;
import it.cnr.isti.workflow.manager.blocks.factories.ConditionalBlockFactory;
import it.cnr.isti.workflow.manager.blocks.factories.SwitchBlockFactory;
import it.cnr.isti.workflow.manager.blocks.types.ConditionalBlockType;
import it.cnr.isti.workflow.manager.blocks.types.SwitchBlockType;
import it.cnr.isti.workflow.manager.containers.Container;
import it.cnr.isti.workflow.manager.containers.configurations.ContainerConfiguration;
import it.cnr.isti.workflow.manager.containers.factories.ContainerFactory;
@ -66,7 +69,7 @@ public class FlowDataValidator implements ConstraintValidator<ValidFlowStructure
validateConnection(connection, nodesById);
}
validateConditionalBranches(nodes, connections, nodesById);
validateExclusiveRoutingBranches(nodes, connections, nodesById);
}
private void validateNode(FlowNode node) {
@ -179,7 +182,7 @@ public class FlowDataValidator implements ConstraintValidator<ValidFlowStructure
return descriptors.stream().anyMatch(descriptor -> name.equals(descriptor.getName()));
}
private void validateConditionalBranches(List<FlowNode> nodes, List<Connection> connections,
private void validateExclusiveRoutingBranches(List<FlowNode> nodes, List<Connection> connections,
Map<String, FlowNode> nodesById) {
Map<String, List<Connection>> outgoingBySource = new HashMap<>();
for (Connection connection : connections) {
@ -188,29 +191,53 @@ public class FlowDataValidator implements ConstraintValidator<ValidFlowStructure
}
for (FlowNode node : nodes) {
if (!(node instanceof Block<?> block) || !ConditionalBlockType.TYPE.equals(block.getType().getName())) {
if (!(node instanceof Block<?> block)) {
continue;
}
List<String> exclusiveOutputs = exclusiveRoutingOutputs(block);
if (exclusiveOutputs.size() < 2) {
continue;
}
Set<String> trueBranch = collectReachableTargets(block.getId(), ConditionalBlockFactory.TRUE_OUTPUT, outgoingBySource);
Set<String> falseBranch = collectReachableTargets(block.getId(), ConditionalBlockFactory.FALSE_OUTPUT, outgoingBySource);
Map<String, Set<String>> reachableByOutput = new LinkedHashMap<>();
for (String outputName : exclusiveOutputs) {
reachableByOutput.put(outputName, collectReachableTargets(block.getId(), outputName, outgoingBySource));
}
Set<String> mergedNodes = new LinkedHashSet<>(trueBranch);
mergedNodes.retainAll(falseBranch);
if (!mergedNodes.isEmpty()) {
String mergedNodeNames = mergedNodes.stream()
.map(nodeId -> {
FlowNode target = nodesById.get(nodeId);
return target == null ? nodeId : target.getName() + " (" + nodeId + ")";
})
.reduce((left, right) -> left + ", " + right)
.orElse("unknown");
throw validationError(error("block", block.getId(), "outputs",
"Conditional true/false branches must not merge. Shared downstream nodes: " + mergedNodeNames));
for (int leftIndex = 0; leftIndex < exclusiveOutputs.size(); leftIndex++) {
for (int rightIndex = leftIndex + 1; rightIndex < exclusiveOutputs.size(); rightIndex++) {
String leftOutput = exclusiveOutputs.get(leftIndex);
String rightOutput = exclusiveOutputs.get(rightIndex);
Set<String> mergedNodes = new LinkedHashSet<>(reachableByOutput.get(leftOutput));
mergedNodes.retainAll(reachableByOutput.get(rightOutput));
if (!mergedNodes.isEmpty()) {
String mergedNodeNames = mergedNodes.stream()
.map(nodeId -> {
FlowNode target = nodesById.get(nodeId);
return target == null ? nodeId : target.getName() + " (" + nodeId + ")";
})
.reduce((first, second) -> first + ", " + second)
.orElse("unknown");
throw validationError(error("block", block.getId(), "outputs",
"Exclusive routing branches must not merge. Shared downstream nodes: " + mergedNodeNames));
}
}
}
}
}
private List<String> exclusiveRoutingOutputs(Block<?> block) {
if (ConditionalBlockType.TYPE.equals(block.getType().getName())) {
return List.of(ConditionalBlockFactory.TRUE_OUTPUT, ConditionalBlockFactory.FALSE_OUTPUT);
}
if (SwitchBlockType.TYPE.equals(block.getType().getName())) {
return block.getOutputs().stream()
.map(IODescriptor::getName)
.toList();
}
return List.of();
}
private Set<String> collectReachableTargets(String conditionalId, String sourceOutput,
Map<String, List<Connection>> outgoingBySource) {
Set<String> visited = new LinkedHashSet<>();

View File

@ -3,10 +3,12 @@ package it.cnr.isti.workflow.manager.controllers;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.TestPropertySource;
import it.cnr.isti.workflow.manager.auth.model.AuthRequest;
import it.cnr.isti.workflow.manager.auth.model.ChangePasswordRequest;
@SpringBootTest
@TestPropertySource(locations = "classpath:test.properties")
@ -20,4 +22,43 @@ public class AuthControllerTest {
ResponseEntity<?> response = authController.login(new AuthRequest("testuser", "testpassword"));
assert response.getStatusCode().is2xxSuccessful();
}
@Test
public void testChangePassword() {
ChangePasswordRequest request = new ChangePasswordRequest();
request.setUsername("testuser");
request.setOldPassword("testpassword");
request.setNewPassword("Newpassword1!");
ResponseEntity<?> response = authController.changePassword(request);
assert response.getStatusCode().is2xxSuccessful();
ResponseEntity<?> loginResponse = authController.login(new AuthRequest("testuser", "Newpassword1!"));
assert loginResponse.getStatusCode().is2xxSuccessful();
}
@Test
public void testChangePasswordReturnsUnauthorizedForInvalidCurrentPassword() {
ChangePasswordRequest request = new ChangePasswordRequest();
request.setUsername("testuser");
request.setOldPassword("wrong-password");
request.setNewPassword("Anotherpass1!");
ResponseEntity<?> response = authController.changePassword(request);
assert response.getStatusCode() == HttpStatus.UNAUTHORIZED;
}
@Test
public void testChangePasswordReturnsInvalidNewPasswordForWeakPassword() {
authController.register(new AuthRequest("weakpwduser", "Startpass1!"));
ChangePasswordRequest request = new ChangePasswordRequest();
request.setUsername("weakpwduser");
request.setOldPassword("Startpass1!");
request.setNewPassword("weakpass");
ResponseEntity<?> response = authController.changePassword(request);
assert response.getStatusCode() == HttpStatus.BAD_REQUEST;
assert "INVALID_NEW_PASSWORD".equals(response.getBody());
}
}

View File

@ -29,11 +29,14 @@ import it.cnr.isti.workflow.manager.blocks.configurations.HTTPServerCallBlockCon
import it.cnr.isti.workflow.manager.blocks.configurations.LLMBlockConfiguration;
import it.cnr.isti.workflow.manager.blocks.configurations.MCPAgentBlockConfiguration;
import it.cnr.isti.workflow.manager.blocks.configurations.MCPAgentChatBlockConfiguration;
import it.cnr.isti.workflow.manager.blocks.configurations.SwitchBlockConfiguration;
import it.cnr.isti.workflow.manager.blocks.configurations.SwitchCase;
import it.cnr.isti.workflow.manager.controllers.BlocksController.BlockConfigurationDescriptor;
import it.cnr.isti.workflow.manager.blocks.types.HTTPServerCallBlockType;
import it.cnr.isti.workflow.manager.blocks.types.LLMBlockType;
import it.cnr.isti.workflow.manager.blocks.types.MCPAgentBlockType;
import it.cnr.isti.workflow.manager.blocks.types.MCPAgentChatBlockType;
import it.cnr.isti.workflow.manager.blocks.types.SwitchBlockType;
import it.cnr.isti.workflow.manager.configurations.annotations.UiContextKeys;
import it.cnr.isti.workflow.manager.configurations.retrievers.ExecutionVariablesFieldRetriever;
import it.cnr.isti.workflow.manager.flows.model.FlowCreateRequest;
@ -747,6 +750,40 @@ public class BlocksControllerTest {
assertEquals("true", prompt.path("x-ui-required-when").path("equals").asText());
}
@Test
public void switchBlockSchemaContainsConditionalRequiredMetadata() {
BlockConfigurationDescriptor descriptor = blocksController
.getConfigurationDescriptorForType(SwitchBlockType.TYPE);
assertNotNull(descriptor.schema());
JsonNode schema = (JsonNode) descriptor.schema();
JsonNode llmDescriptor = schema.path("properties").path("llmDescriptor");
JsonNode prompt = schema.path("properties").path("prompt");
assertEquals("useLlm", llmDescriptor.path("x-ui-required-when").path("field").asText());
assertEquals("true", llmDescriptor.path("x-ui-required-when").path("equals").asText());
assertEquals("useLlm", prompt.path("x-ui-required-when").path("field").asText());
assertEquals("true", prompt.path("x-ui-required-when").path("equals").asText());
}
@Test
public void createSwitchBlockCreatesUserDefinedOutputs() {
Block<SwitchBlockType> block = blocksController.create(SwitchBlockConfiguration.builder()
.name("Decision")
.useLlm(false)
.condition("'approved'")
.outputTemplate("${{candidate}}")
.cases(List.of(new SwitchCase("approved"), new SwitchCase("rejected"), new SwitchCase("review")))
.build());
assertNotNull(block);
assertEquals(SwitchBlockType.TYPE, block.getType().getName());
assertEquals(3, block.getOutputs().size());
assertTrue(block.getOutputs().stream().anyMatch(output -> output.getName().equals("approved")));
assertTrue(block.getOutputs().stream().anyMatch(output -> output.getName().equals("rejected")));
assertTrue(block.getOutputs().stream().anyMatch(output -> output.getName().equals("review")));
}
@Test
public void getMCPBridgeExampleForType() {
Block<MCPAgentBlockType> block = blocksController.getExampleForType(MCPAgentBlockType.TYPE);

View File

@ -30,10 +30,13 @@ import it.cnr.isti.workflow.manager.blocks.Block;
import it.cnr.isti.workflow.manager.blocks.configurations.ConditionalBlockConfiguration;
import it.cnr.isti.workflow.manager.blocks.configurations.LLMBlockConfiguration;
import it.cnr.isti.workflow.manager.blocks.configurations.MCPAgentBlockConfiguration;
import it.cnr.isti.workflow.manager.blocks.configurations.SwitchBlockConfiguration;
import it.cnr.isti.workflow.manager.blocks.configurations.SwitchCase;
import it.cnr.isti.workflow.manager.blocks.factories.ConditionalBlockFactory;
import it.cnr.isti.workflow.manager.blocks.types.ConditionalBlockType;
import it.cnr.isti.workflow.manager.blocks.types.LLMBlockType;
import it.cnr.isti.workflow.manager.blocks.types.MCPAgentBlockType;
import it.cnr.isti.workflow.manager.blocks.types.SwitchBlockType;
import it.cnr.isti.workflow.manager.containers.Container;
import it.cnr.isti.workflow.manager.containers.configurations.IteratorContainerConfiguration;
import it.cnr.isti.workflow.manager.containers.types.IteratorContainerType;
@ -870,6 +873,94 @@ public class FlowControllerTest {
() -> flowController.createFlow(request, new LoginEntity("testuser", "testpassword")));
assertEquals(HttpStatus.BAD_REQUEST, exception.getStatusCode());
assertTrue(exception.getReason().contains("Conditional true/false branches must not merge"));
assertTrue(exception.getReason().contains("Exclusive routing branches must not merge"));
}
@Test
public void createFlowRejectsSwitchBranchMerge() {
LLMDescriptor llmDescriptor = LLMDescriptor.builder()
.provider("testProvider")
.model("testModel")
.build();
Block<LLMBlockType> source = blocksController.create(LLMBlockConfiguration.builder()
.name("Source")
.llmDescriptor(llmDescriptor)
.prompt("Classify candidate from ${{candidate}}")
.build());
Block<SwitchBlockType> switchBlock = blocksController.create(SwitchBlockConfiguration.builder()
.name("Decision")
.condition("'approved'")
.useLlm(false)
.outputTemplate("${{response}}")
.cases(List.of(new SwitchCase("approved"), new SwitchCase("rejected"), new SwitchCase("review")))
.build());
Block<LLMBlockType> approvedBranch = blocksController.create(LLMBlockConfiguration.builder()
.name("Approved branch")
.llmDescriptor(llmDescriptor)
.prompt("Summarize approved outcome from ${{response}}")
.build());
Block<LLMBlockType> reviewBranch = blocksController.create(LLMBlockConfiguration.builder()
.name("Review branch")
.llmDescriptor(llmDescriptor)
.prompt("Summarize review outcome from ${{response}}")
.build());
Block<LLMBlockType> merged = blocksController.create(LLMBlockConfiguration.builder()
.name("Merged result")
.llmDescriptor(llmDescriptor)
.prompt("Combine approved: ${{fromApproved}} and review: ${{fromReview}}")
.build());
FlowCreateRequest request = new FlowCreateRequest(
"Invalid switch merge",
"Switch branches must not merge",
FlowData.builder()
.block(source)
.block(switchBlock)
.block(approvedBranch)
.block(reviewBranch)
.block(merged)
.connection(Connection.builder()
.sourceId(source.getId())
.sourceName("response")
.targetId(switchBlock.getId())
.targetName("response")
.build())
.connection(Connection.builder()
.sourceId(switchBlock.getId())
.sourceName("approved")
.targetId(approvedBranch.getId())
.targetName("response")
.build())
.connection(Connection.builder()
.sourceId(switchBlock.getId())
.sourceName("review")
.targetId(reviewBranch.getId())
.targetName("response")
.build())
.connection(Connection.builder()
.sourceId(approvedBranch.getId())
.sourceName("response")
.targetId(merged.getId())
.targetName("fromApproved")
.build())
.connection(Connection.builder()
.sourceId(reviewBranch.getId())
.sourceName("response")
.targetId(merged.getId())
.targetName("fromReview")
.build())
.build());
ResponseStatusException exception = assertThrows(
ResponseStatusException.class,
() -> flowController.createFlow(request, new LoginEntity("testuser", "testpassword")));
assertEquals(HttpStatus.BAD_REQUEST, exception.getStatusCode());
assertTrue(exception.getReason().contains("Exclusive routing branches must not merge"));
}
}

View File

@ -31,6 +31,8 @@ import it.cnr.isti.workflow.manager.blocks.configurations.ChatInteractionInput;
import it.cnr.isti.workflow.manager.blocks.configurations.HumanInteractiveBlockConfiguration;
import it.cnr.isti.workflow.manager.blocks.configurations.MCPAgentBlockConfiguration;
import it.cnr.isti.workflow.manager.blocks.configurations.MCPAgentChatBlockConfiguration;
import it.cnr.isti.workflow.manager.blocks.configurations.SwitchBlockConfiguration;
import it.cnr.isti.workflow.manager.blocks.configurations.SwitchCase;
import it.cnr.isti.workflow.manager.containers.Container;
import it.cnr.isti.workflow.manager.containers.configurations.ContainerConfiguration;
import it.cnr.isti.workflow.manager.containers.configurations.GenericContainerConfiguration;
@ -54,12 +56,14 @@ import it.cnr.isti.workflow.manager.blocks.factories.HTTPServerCallBlockFactory;
import it.cnr.isti.workflow.manager.blocks.factories.LLMBlockFactory;
import it.cnr.isti.workflow.manager.blocks.factories.MCPAgentBlockFactory;
import it.cnr.isti.workflow.manager.blocks.factories.MCPAgentChatBlockFactory;
import it.cnr.isti.workflow.manager.blocks.factories.SwitchBlockFactory;
import it.cnr.isti.workflow.manager.blocks.types.ChatInteractionBlockType;
import it.cnr.isti.workflow.manager.blocks.types.HumanInteractionBlockType;
import it.cnr.isti.workflow.manager.blocks.types.HTTPServerCallBlockType;
import it.cnr.isti.workflow.manager.blocks.types.LLMBlockType;
import it.cnr.isti.workflow.manager.blocks.types.MCPAgentBlockType;
import it.cnr.isti.workflow.manager.blocks.types.MCPAgentChatBlockType;
import it.cnr.isti.workflow.manager.blocks.types.SwitchBlockType;
import it.cnr.isti.workflow.manager.executions.ExecutionVariableKind;
import it.cnr.isti.workflow.manager.executions.ExecutionEventType;
import it.cnr.isti.workflow.manager.ios.IODescriptor;
@ -104,6 +108,9 @@ public class ExecutionTest {
if (model.contains("simulate") && prompt.contains("###SIMULATED_CHAT_FINAL###")) {
return "FINAL: Candidate approved";
}
if (prompt.contains("You are a workflow switch evaluator.")) {
return "{\"result\":\"approved\"}";
}
return "Hello, " + prompt.replace("Hello, ", "").replace("!", "") + "!";
}
@ -135,6 +142,9 @@ public class ExecutionTest {
@Autowired
MCPAgentBlockFactory mcpAgentBlockFactory;
@Autowired
SwitchBlockFactory switchBlockFactory;
@Autowired
HumanInteractiveBlockFactory humanInteractiveBlockFactory;
@ -546,6 +556,65 @@ public class ExecutionTest {
assertEquals("Ada Lovelace", restored.getContext().getExecutionVariables().get("person"));
}
@Test
public void switchExecutionRoutesToConfiguredOutputUsingExpression() {
Block<SwitchBlockType> switchBlock = switchBlockFactory.create(SwitchBlockConfiguration.builder()
.name("Decision")
.cases(List.of(new SwitchCase("approved"), new SwitchCase("rejected")))
.condition("${{decision}}")
.useLlm(false)
.outputTemplate("Outcome for ${{candidate}}")
.build());
Flow flow = Flow.builder()
.name("Switch flow")
.description("Single switch block")
.block(switchBlock)
.build();
ExecutionObject execObject = executionsService.createExecution(flow);
execObject = executionsService.prepareInput(execObject.getId(), switchBlock.getId(), "decision", "approved");
execObject = executionsService.prepareInput(execObject.getId(), switchBlock.getId(), "candidate", "John Doe");
execObject = executionsService.startExecution(execObject.getId());
while (execObject.getContext().getStatus() == ExecutionStatus.RUNNING) {
execObject = executionsService.getExecution(execObject.getId());
}
assertEquals(ExecutionStatus.SUCCESS, execObject.getContext().getStatus());
assertEquals("Outcome for John Doe",
execObject.getContext().getResult().get(new FieldKey(switchBlock.getId(), "approved")));
assertFalse(execObject.getContext().getResult().containsKey(new FieldKey(switchBlock.getId(), "rejected")));
}
@Test
public void switchExecutionRoutesToConfiguredOutputUsingLlm() {
Block<SwitchBlockType> switchBlock = switchBlockFactory.create(SwitchBlockConfiguration.builder()
.name("Decision")
.cases(List.of(new SwitchCase("approved"), new SwitchCase("rejected")))
.useLlm(true)
.llmDescriptor(llmBrick)
.prompt("Choose the output for candidate ${{candidate}}")
.outputTemplate("Outcome for ${{candidate}}")
.build());
Flow flow = Flow.builder()
.name("Switch flow llm")
.description("Single switch block with llm")
.block(switchBlock)
.build();
ExecutionObject execObject = executionsService.createExecution(flow);
execObject = executionsService.prepareInput(execObject.getId(), switchBlock.getId(), "candidate", "John Doe");
execObject = executionsService.startExecution(execObject.getId());
while (execObject.getContext().getStatus() == ExecutionStatus.RUNNING) {
execObject = executionsService.getExecution(execObject.getId());
}
assertEquals(ExecutionStatus.SUCCESS, execObject.getContext().getStatus());
assertEquals("Outcome for John Doe",
execObject.getContext().getResult().get(new FieldKey(switchBlock.getId(), "approved")));
}
@Test
public void cancelExecutionClearsExecutionVariables() {
Block<HumanInteractionBlockType> reviewBlock = humanInteractiveBlockFactory.create(HumanInteractiveBlockConfiguration.builder()