diff --git a/src/main/java/it/cnr/isti/workflow/manager/assistant/FlowAssistantService.java b/src/main/java/it/cnr/isti/workflow/manager/assistant/FlowAssistantService.java index 65eb86c..0626b37 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/assistant/FlowAssistantService.java +++ b/src/main/java/it/cnr/isti/workflow/manager/assistant/FlowAssistantService.java @@ -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 -> { } } diff --git a/src/main/java/it/cnr/isti/workflow/manager/auth/model/ChangePasswordRequest.java b/src/main/java/it/cnr/isti/workflow/manager/auth/model/ChangePasswordRequest.java index e78e298..b43dd51 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/auth/model/ChangePasswordRequest.java +++ b/src/main/java/it/cnr/isti/workflow/manager/auth/model/ChangePasswordRequest.java @@ -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; diff --git a/src/main/java/it/cnr/isti/workflow/manager/auth/services/AuthService.java b/src/main/java/it/cnr/isti/workflow/manager/auth/services/AuthService.java index 5a47fa0..5e8f980 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/auth/services/AuthService.java +++ b/src/main/java/it/cnr/isti/workflow/manager/auth/services/AuthService.java @@ -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; + } } diff --git a/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/SwitchBlockConfiguration.java b/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/SwitchBlockConfiguration.java new file mode 100644 index 0000000..ad02465 --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/SwitchBlockConfiguration.java @@ -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 { + + @Structural + @UiUniqueItemsBy("name") + @Size(max = 10) + @Valid + @JsonProperty(required = false) + private List 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 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 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; + } +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/SwitchCase.java b/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/SwitchCase.java new file mode 100644 index 0000000..668ecbf --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/SwitchCase.java @@ -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) { +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/blocks/factories/SwitchBlockFactory.java b/src/main/java/it/cnr/isti/workflow/manager/blocks/factories/SwitchBlockFactory.java new file mode 100644 index 0000000..c6d7fd7 --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/blocks/factories/SwitchBlockFactory.java @@ -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 { + + private static final List INPUT_CAPABILITIES = List.of( + new IOCapability(IOCapabilityType.TEXT, false), + new IOCapability(IOCapabilityType.TEXT, true)); + private static final List 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 create(SwitchBlockConfiguration configuration) { + List inputs = retrieveInputs(configuration).stream() + .map(inputName -> IODescriptor.input(inputName, IOType.TEXT, false, INPUT_CAPABILITIES)) + .toList(); + + Block.BlockBuilder builder = Block.builder() + .inputs(inputs) + .specificConfiguration(configuration) + .type(blockType); + for (SwitchCase outputCase : configuration.getCases() == null ? List.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 createEmpty() { + return create(SwitchBlockConfiguration.empty()); + } + + @Override + public Class getBlockType() { + return SwitchBlockType.class; + } + + private List retrieveInputs(SwitchBlockConfiguration configuration) { + Set 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 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 supportedInputCapabilities() { + return INPUT_CAPABILITIES; + } + + @Override + public List supportedOutputCapabilities() { + return OUTPUT_CAPABILITIES; + } +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/blocks/types/SwitchBlockType.java b/src/main/java/it/cnr/isti/workflow/manager/blocks/types/SwitchBlockType.java new file mode 100644 index 0000000..8c54b95 --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/blocks/types/SwitchBlockType.java @@ -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> getBlockConfigurationClass() { + return SwitchBlockConfiguration.class; + } +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/controllers/AuthController.java b/src/main/java/it/cnr/isti/workflow/manager/controllers/AuthController.java index f8978ab..b503370 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/controllers/AuthController.java +++ b/src/main/java/it/cnr/isti/workflow/manager/controllers/AuthController.java @@ -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(); } } diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionsService.java b/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionsService.java index d37b523..3d86351 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionsService.java +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionsService.java @@ -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(); } diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/SwitchExecutor.java b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/SwitchExecutor.java new file mode 100644 index 0000000..99d5f90 --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/SwitchExecutor.java @@ -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 { + + 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 llmProviders; + + @Override + public Map execute(Block block, List inputs, + Map authorizations, Map executionVariables, + Map executionVariableDescriptors, ExecutionEventLogger eventLogger) { + SwitchBlockConfiguration config = (SwitchBlockConfiguration) block.getSpecificConfiguration(); + Map inputValues = collectInputValues(inputs); + Set 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 getBlockType() { + return SwitchBlockType.class; + } + + private Map collectInputValues(List inputs) { + Map values = new LinkedHashMap<>(); + for (Input input : inputs) { + values.put(input.getDescriptor().getName(), input.getValue()); + } + return values; + } + + private String evaluateWithExpression(String expression, Map inputValues, + Map executionVariables, Set 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 inputValues, + Map authorizations, Map executionVariables, Set 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 inputValues, + Map executionVariables, Set 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 inputValues, + Map executionVariables) { + return ExecutionTemplateResolver.resolve(template, inputValues, executionVariables); + } + + private String parseSelectedOutput(String response, Set 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 payload = objectMapper.readValue(trimmed, new TypeReference>() { + }); + 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 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; + } +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/flows/validation/FlowDataValidator.java b/src/main/java/it/cnr/isti/workflow/manager/flows/validation/FlowDataValidator.java index 39d6eb4..9393ed1 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/flows/validation/FlowDataValidator.java +++ b/src/main/java/it/cnr/isti/workflow/manager/flows/validation/FlowDataValidator.java @@ -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 name.equals(descriptor.getName())); } - private void validateConditionalBranches(List nodes, List connections, + private void validateExclusiveRoutingBranches(List nodes, List connections, Map nodesById) { Map> outgoingBySource = new HashMap<>(); for (Connection connection : connections) { @@ -188,29 +191,53 @@ public class FlowDataValidator implements ConstraintValidator block) || !ConditionalBlockType.TYPE.equals(block.getType().getName())) { + if (!(node instanceof Block block)) { + continue; + } + List exclusiveOutputs = exclusiveRoutingOutputs(block); + if (exclusiveOutputs.size() < 2) { continue; } - Set trueBranch = collectReachableTargets(block.getId(), ConditionalBlockFactory.TRUE_OUTPUT, outgoingBySource); - Set falseBranch = collectReachableTargets(block.getId(), ConditionalBlockFactory.FALSE_OUTPUT, outgoingBySource); + Map> reachableByOutput = new LinkedHashMap<>(); + for (String outputName : exclusiveOutputs) { + reachableByOutput.put(outputName, collectReachableTargets(block.getId(), outputName, outgoingBySource)); + } - Set 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 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 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 collectReachableTargets(String conditionalId, String sourceOutput, Map> outgoingBySource) { Set visited = new LinkedHashSet<>(); diff --git a/src/test/java/it/cnr/isti/workflow/manager/controllers/AuthControllerTest.java b/src/test/java/it/cnr/isti/workflow/manager/controllers/AuthControllerTest.java index 7a73c3c..bcc9439 100644 --- a/src/test/java/it/cnr/isti/workflow/manager/controllers/AuthControllerTest.java +++ b/src/test/java/it/cnr/isti/workflow/manager/controllers/AuthControllerTest.java @@ -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()); + } } diff --git a/src/test/java/it/cnr/isti/workflow/manager/controllers/BlocksControllerTest.java b/src/test/java/it/cnr/isti/workflow/manager/controllers/BlocksControllerTest.java index 08c51c0..3afd590 100644 --- a/src/test/java/it/cnr/isti/workflow/manager/controllers/BlocksControllerTest.java +++ b/src/test/java/it/cnr/isti/workflow/manager/controllers/BlocksControllerTest.java @@ -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 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 block = blocksController.getExampleForType(MCPAgentBlockType.TYPE); diff --git a/src/test/java/it/cnr/isti/workflow/manager/controllers/FlowControllerTest.java b/src/test/java/it/cnr/isti/workflow/manager/controllers/FlowControllerTest.java index 59fb7e9..72646fa 100644 --- a/src/test/java/it/cnr/isti/workflow/manager/controllers/FlowControllerTest.java +++ b/src/test/java/it/cnr/isti/workflow/manager/controllers/FlowControllerTest.java @@ -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 source = blocksController.create(LLMBlockConfiguration.builder() + .name("Source") + .llmDescriptor(llmDescriptor) + .prompt("Classify candidate from ${{candidate}}") + .build()); + + Block 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 approvedBranch = blocksController.create(LLMBlockConfiguration.builder() + .name("Approved branch") + .llmDescriptor(llmDescriptor) + .prompt("Summarize approved outcome from ${{response}}") + .build()); + + Block reviewBranch = blocksController.create(LLMBlockConfiguration.builder() + .name("Review branch") + .llmDescriptor(llmDescriptor) + .prompt("Summarize review outcome from ${{response}}") + .build()); + + Block 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")); } } diff --git a/src/test/java/it/cnr/isti/workflow/manager/executions/ExecutionTest.java b/src/test/java/it/cnr/isti/workflow/manager/executions/ExecutionTest.java index ea976f6..503588a 100644 --- a/src/test/java/it/cnr/isti/workflow/manager/executions/ExecutionTest.java +++ b/src/test/java/it/cnr/isti/workflow/manager/executions/ExecutionTest.java @@ -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 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 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 reviewBlock = humanInteractiveBlockFactory.create(HumanInteractiveBlockConfiguration.builder()