From d119fd8a90959546591a8ddd6af6804665de1848 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 24 Nov 2025 16:32:00 -0600 Subject: [PATCH 1/5] feat(multiForkingSteps): Add support for multi-forking / variable-output steps including: - allowedDescendantStepTypes property on workflowStepType - add workflow-level validator - new field in WorkflowStepOutput to indicate that a step within a multi-forking execution should terminate the entire workflow. - allow null toStep to be stored in a link, to help track flow out of variable steps at end-of-graph - replace GetWorkflowStepSummaryProcess with GetWorkflowStepDynamicValuesProcess, which also includes outboundLinkOptions for VARIABLE mode steps --- .../definition/OutboundLinkMode.java | 3 +- .../definition/WorkflowStepType.java | 53 +++++ .../workflows/definition/WorkflowType.java | 45 ++++ .../workflows/execution/ExecutionPayload.java | 41 ++++ .../execution/FindFirstJoinPoint.java | 167 +++++++++++++ .../execution/WorkflowExecutionContext.java | 24 ++ .../workflows/execution/WorkflowExecutor.java | 116 ++++++--- ...flowMultiForkingStepExecutorInterface.java | 134 +++++++++++ .../execution/WorkflowStepOutput.java | 16 +- .../WorkflowTypeValidatorInterface.java | 51 ++++ .../execution/nodes/NodeSequence.java | 72 ++++++ .../workflows/execution/nodes/StepNode.java | 47 ++++ .../execution/nodes/StepNodeGraphBuilder.java | 224 ++++++++++++++++++ .../qbits/workflows/model/WorkflowLink.java | 2 +- .../GetWorkflowStepDynamicValuesProcess.java | 116 +++++++++ .../GetWorkflowStepSummaryProcess.java | 2 +- .../StoreNewWorkflowRevisionProcess.java | 14 +- .../workflows/TestWorkflowDefinitions.java | 63 +++++ .../workflows/WorkflowsTestDataSource.java | 3 +- .../execution/FindFirstJoinPointTest.java | 130 ++++++++++ .../execution/WorkflowExecutorTest.java | 185 +++++++++++++++ .../nodes/StepNodeGraphBuilderTest.java | 172 ++++++++++++++ .../StoreNewWorkflowRevisionProcessTest.java | 2 +- 23 files changed, 1640 insertions(+), 42 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/workflows/execution/ExecutionPayload.java create mode 100644 src/main/java/com/kingsrook/qbits/workflows/execution/FindFirstJoinPoint.java create mode 100644 src/main/java/com/kingsrook/qbits/workflows/execution/WorkflowMultiForkingStepExecutorInterface.java create mode 100644 src/main/java/com/kingsrook/qbits/workflows/execution/WorkflowTypeValidatorInterface.java create mode 100644 src/main/java/com/kingsrook/qbits/workflows/execution/nodes/NodeSequence.java create mode 100644 src/main/java/com/kingsrook/qbits/workflows/execution/nodes/StepNode.java create mode 100644 src/main/java/com/kingsrook/qbits/workflows/execution/nodes/StepNodeGraphBuilder.java create mode 100644 src/main/java/com/kingsrook/qbits/workflows/processes/GetWorkflowStepDynamicValuesProcess.java create mode 100644 src/test/java/com/kingsrook/qbits/workflows/execution/FindFirstJoinPointTest.java create mode 100644 src/test/java/com/kingsrook/qbits/workflows/execution/nodes/StepNodeGraphBuilderTest.java diff --git a/src/main/java/com/kingsrook/qbits/workflows/definition/OutboundLinkMode.java b/src/main/java/com/kingsrook/qbits/workflows/definition/OutboundLinkMode.java index a292bb5..db221a7 100644 --- a/src/main/java/com/kingsrook/qbits/workflows/definition/OutboundLinkMode.java +++ b/src/main/java/com/kingsrook/qbits/workflows/definition/OutboundLinkMode.java @@ -28,7 +28,7 @@ package com.kingsrook.qbits.workflows.definition; ** ONE is a regular statement, that just flows to its next. ** CONTAINER is itself a no-op, but it wraps (for UI reasons) a sequence ** TWO is for an if/else - ** Unclear exactly how we'd do variable number of outbounds (e.g., a switch) + ** VARIABLE is for a variable number - 1 or more ***************************************************************************/ public enum OutboundLinkMode { @@ -36,4 +36,5 @@ public enum OutboundLinkMode ONE, CONTAINER, TWO, + VARIABLE } diff --git a/src/main/java/com/kingsrook/qbits/workflows/definition/WorkflowStepType.java b/src/main/java/com/kingsrook/qbits/workflows/definition/WorkflowStepType.java index 9d80504..ebfd8f9 100644 --- a/src/main/java/com/kingsrook/qbits/workflows/definition/WorkflowStepType.java +++ b/src/main/java/com/kingsrook/qbits/workflows/definition/WorkflowStepType.java @@ -26,6 +26,7 @@ import java.io.Serializable; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Set; import com.google.gson.reflect.TypeToken; import com.kingsrook.qbits.workflows.execution.WorkflowStepExecutorInterface; import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader; @@ -56,6 +57,8 @@ public class WorkflowStepType implements Serializable private OutboundLinkMode outboundLinkMode; private List outboundLinkOptions; + private Set allowedDescendantStepTypes; + private ArrayList inputFields = new ArrayList<>(); private ArrayList inputWidgetNames = new ArrayList<>(); @@ -83,6 +86,17 @@ public class WorkflowStepType implements Serializable + /*************************************************************************** + * for a variable-output type step, it must dynamically define its outbound + * links + ***************************************************************************/ + public List getDynamicOutboundLinkOptions(Integer workflowId, Map inputValues) throws QException + { + return (null); + } + + + /*************************************************************************** ** ***************************************************************************/ @@ -445,4 +459,43 @@ public class WorkflowStepType implements Serializable return (this); } + + + /******************************************************************************* + * Getter for allowedDescendantStepTypes + * @see #withAllowedDescendantStepTypes(Set) + *******************************************************************************/ + public Set getAllowedDescendantStepTypes() + { + return (this.allowedDescendantStepTypes); + } + + + + /******************************************************************************* + * Setter for allowedDescendantStepTypes + * @see #withAllowedDescendantStepTypes(Set) + *******************************************************************************/ + public void setAllowedDescendantStepTypes(Set allowedDescendantStepTypes) + { + this.allowedDescendantStepTypes = allowedDescendantStepTypes; + } + + + + /******************************************************************************* + * Fluent setter for allowedDescendantStepTypes + * + * @param allowedDescendantStepTypes + * to limit what step types can be placed inside this step type within a graph. + * for example, + * @return this + *******************************************************************************/ + public WorkflowStepType withAllowedDescendantStepTypes(Set allowedDescendantStepTypes) + { + this.allowedDescendantStepTypes = allowedDescendantStepTypes; + return (this); + } + + } diff --git a/src/main/java/com/kingsrook/qbits/workflows/definition/WorkflowType.java b/src/main/java/com/kingsrook/qbits/workflows/definition/WorkflowType.java index b0bd993..d956781 100644 --- a/src/main/java/com/kingsrook/qbits/workflows/definition/WorkflowType.java +++ b/src/main/java/com/kingsrook/qbits/workflows/definition/WorkflowType.java @@ -31,6 +31,7 @@ import java.util.Map; import com.google.gson.reflect.TypeToken; import com.kingsrook.qbits.workflows.execution.WorkflowTypeExecutorInterface; import com.kingsrook.qbits.workflows.execution.WorkflowTypeTesterInterface; +import com.kingsrook.qbits.workflows.execution.WorkflowTypeValidatorInterface; import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher; import com.kingsrook.qqq.backend.core.instances.QInstanceValidator; import com.kingsrook.qqq.backend.core.model.data.QRecord; @@ -52,6 +53,7 @@ public class WorkflowType implements Serializable private String label; private String description; private QCodeReference executor; + private QCodeReference validator; private QCodeReference tester; protected Map> helpContent; @@ -84,6 +86,11 @@ public class WorkflowType implements Serializable qInstanceValidator.validateSimpleCodeReference("WorkflowType [" + name + "]: executor ", executor, WorkflowTypeExecutorInterface.class); } + if(validator != null) + { + qInstanceValidator.validateSimpleCodeReference("WorkflowType [" + name + "]: validator ", validator, WorkflowTypeValidatorInterface.class); + } + if(tester != null) { qInstanceValidator.validateSimpleCodeReference("WorkflowType [" + name + "]: tester ", tester, WorkflowTypeTesterInterface.class); @@ -358,4 +365,42 @@ public class WorkflowType implements Serializable return (this); } + + /******************************************************************************* + * Getter for validator + * @see #withValidator(QCodeReference) + *******************************************************************************/ + public QCodeReference getValidator() + { + return (this.validator); + } + + + + /******************************************************************************* + * Setter for validator + * @see #withValidator(QCodeReference) + *******************************************************************************/ + public void setValidator(QCodeReference validator) + { + this.validator = validator; + } + + + + /******************************************************************************* + * Fluent setter for validator + * + * @param validator reference to implementation of + * {@link com.kingsrook.qbits.workflows.execution.WorkflowTypeValidatorInterface} + * + * @return this + *******************************************************************************/ + public WorkflowType withValidator(QCodeReference validator) + { + this.validator = validator; + return (this); + } + + } diff --git a/src/main/java/com/kingsrook/qbits/workflows/execution/ExecutionPayload.java b/src/main/java/com/kingsrook/qbits/workflows/execution/ExecutionPayload.java new file mode 100644 index 0000000..01b5712 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/workflows/execution/ExecutionPayload.java @@ -0,0 +1,41 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qbits.workflows.execution; + + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import com.kingsrook.qbits.workflows.model.WorkflowLink; +import com.kingsrook.qbits.workflows.model.WorkflowRunLogStep; +import com.kingsrook.qbits.workflows.model.WorkflowStep; +import com.kingsrook.qqq.backend.core.utils.ListingHash; + + +/*************************************************************************** + * state data carried throughout the {@link WorkflowExecutor} - which is + * useful to "leak out" in a managed way, e.g., for step types that do their + * own more sophisticated kind of execution, e.g., {@link WorkflowMultiForkingStepExecutorInterface} + ***************************************************************************/ +record ExecutionPayload(Map stepMap, ListingHash linkMap, WorkflowTypeExecutorInterface workflowTypeExecutor, AtomicInteger seqNo, List logStepList) +{ +} diff --git a/src/main/java/com/kingsrook/qbits/workflows/execution/FindFirstJoinPoint.java b/src/main/java/com/kingsrook/qbits/workflows/execution/FindFirstJoinPoint.java new file mode 100644 index 0000000..1f7135f --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/workflows/execution/FindFirstJoinPoint.java @@ -0,0 +1,167 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qbits.workflows.execution; + + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Deque; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import com.kingsrook.qbits.workflows.model.WorkflowLink; +import com.kingsrook.qbits.workflows.model.WorkflowStep; +import com.kingsrook.qqq.backend.core.utils.CollectionUtils; +import com.kingsrook.qqq.backend.core.utils.ListingHash; + + +/******************************************************************************* + * given a graph, find the first "join point" where multiple forks come together. + * Credit: https://chatgpt.com/share/68000e8f-879c-8011-bb33-0564eb77a1f2 + *******************************************************************************/ +public class FindFirstJoinPoint +{ + + /*************************************************************************** + ** + ***************************************************************************/ + public static Integer executeForFromStepNo(Integer fromStepNo, Map stepMap, ListingHash linksMapByFromStepNo) + { + List startLinks = CollectionUtils.nonNullList(linksMapByFromStepNo.get(fromStepNo)).stream().map(ws -> ws.getToStepNo()).toList(); + return execute(startLinks, stepMap, linksMapByFromStepNo); + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + public static Integer executeForLinks(Collection startLinks, Map stepMap, ListingHash linksMapByFromStepNo) + { + return execute(startLinks.stream().map(ws -> ws.getToStepNo()).toList(), stepMap, linksMapByFromStepNo); + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + @SuppressWarnings("Java8MapApi") + public static Integer execute(Collection startStepNos, Map stepMap, ListingHash linksMapByFromStepNo) + { + ////////////////////////////////////////////////////////////////////////////////// + // visited: map each node to a set of source step numbers that have reached it. // + ////////////////////////////////////////////////////////////////////////////////// + ListingHash visited = new ListingHash<>(); + + /////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // Initialize the multi-source BFS queue. Each entry carries the current node id and its originating source. // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////// + Deque queue = new ArrayDeque<>(); + + //////////////////////////////////////////////////////////////////////////////// + // Seed the queue with each starting node, marking each as visited by itself. // + //////////////////////////////////////////////////////////////////////////////// + for(Integer node : startStepNos) + { + visited.add(node, node); + queue.add(new BFSQueueNode(node, node)); + } + + /////////////////////////////////// + // Perform the multi-source BFS. // + /////////////////////////////////// + while(!queue.isEmpty()) + { + BFSQueueNode firstNode = queue.removeFirst(); + Integer current = firstNode.current; + Integer source = firstNode.source; + + //////////////////////////////////////////////////////////////////// + // Get the list of outgoing links/neighbors for the current node. // + //////////////////////////////////////////////////////////////////// + List neighbors = Objects.requireNonNullElse(linksMapByFromStepNo.get(current), Collections.emptyList()); + for(WorkflowLink neighbor : neighbors) + { + Integer neighborId = neighbor.getToStepNo(); + if(neighborId == null) + { + continue; + } + + /////////////////////////////////////////////////////////////////////////// + // Retrieve or initialize the set of sources that visited this neighbor. // + /////////////////////////////////////////////////////////////////////////// + List sourcesSet = visited.get(neighborId); + if(sourcesSet == null) + { + sourcesSet = new ArrayList<>(); + visited.put(neighborId, sourcesSet); + } + + ///////////////////////////////////////////////////////////////////// + // If the current source has not visited the neighbor yet, add it. // + ///////////////////////////////////////////////////////////////////// + if(!sourcesSet.contains(source)) + { + sourcesSet.add(source); + + ////////////////////////////////////////////////////////////// + // Check if the neighbor was reached from multiple sources. // + // todo: should this be it has to equal all of them? // + // if(sourcesSet.size() == startStepNos.size()) // + ////////////////////////////////////////////////////////////// + if(sourcesSet.size() > 1) + { + /////////////////////////////////////////////////////////////////////////////////////// + // This is the first join point encountered – return the corresponding WorkflowStep. // + /////////////////////////////////////////////////////////////////////////////////////// + return (stepMap.get(neighborId).getStepNo()); + } + + ///////////////////////////////////////////////////////////////// + // Continue the BFS from this neighbor for the current source. // + ///////////////////////////////////////////////////////////////// + queue.push(new BFSQueueNode(neighborId, source)); + } + } + } + + /////////////////////////////////////////////////////////////// + // If no join point was found across all paths, return null. // + /////////////////////////////////////////////////////////////// + return null; + } + + + + /*************************************************************************** + * + ***************************************************************************/ + private record BFSQueueNode(Integer current, Integer source) + { + + } + +} diff --git a/src/main/java/com/kingsrook/qbits/workflows/execution/WorkflowExecutionContext.java b/src/main/java/com/kingsrook/qbits/workflows/execution/WorkflowExecutionContext.java index b68f522..f7c479a 100644 --- a/src/main/java/com/kingsrook/qbits/workflows/execution/WorkflowExecutionContext.java +++ b/src/main/java/com/kingsrook/qbits/workflows/execution/WorkflowExecutionContext.java @@ -42,6 +42,8 @@ public class WorkflowExecutionContext private QBackendTransaction transaction; private WorkflowRunLog workflowRunLog; + private WorkflowExecutor executor; + private boolean isTestRun = false; @@ -235,4 +237,26 @@ public class WorkflowExecutionContext return (this); } + + + /******************************************************************************* + * Getter for executor + * @see #setExecutor(WorkflowExecutor) + *******************************************************************************/ + WorkflowExecutor getExecutor() + { + return (this.executor); + } + + + + /******************************************************************************* + * Setter for executor + * @param executor the object executing the workflow. + *******************************************************************************/ + void setExecutor(WorkflowExecutor executor) + { + this.executor = executor; + } + } diff --git a/src/main/java/com/kingsrook/qbits/workflows/execution/WorkflowExecutor.java b/src/main/java/com/kingsrook/qbits/workflows/execution/WorkflowExecutor.java index 0c340e4..4de6f87 100644 --- a/src/main/java/com/kingsrook/qbits/workflows/execution/WorkflowExecutor.java +++ b/src/main/java/com/kingsrook/qbits/workflows/execution/WorkflowExecutor.java @@ -31,6 +31,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Stack; +import java.util.concurrent.atomic.AtomicInteger; import com.google.gson.reflect.TypeToken; import com.kingsrook.qbits.workflows.definition.OutboundLinkMode; import com.kingsrook.qbits.workflows.definition.WorkflowStepType; @@ -71,6 +72,7 @@ public class WorkflowExecutor extends AbstractQActionBiConsumer containerStack = new Stack<>(); + private ExecutionPayload payload; @@ -167,31 +169,10 @@ public class WorkflowExecutor extends AbstractQActionBiConsumer links = linkMap.get(fromStepNo); - Class stepOutputClass = stepOutput == null ? null : stepOutput.getClass(); + Integer fromStepNo = fromStep.getStepNo(); + List links = linkMap.get(fromStepNo); + + if(OutboundLinkMode.VARIABLE.equals(fromWorkflowStepType.getOutboundLinkMode())) + { + ///////////////////////////////////////////////////////////////////////////// + // after a VARIABLE step (where its branches were all executed in // + // processMultiForkingStep), find the first join point, and step to there. // + ///////////////////////////////////////////////////////////////////////////// + Integer joinStepNo = FindFirstJoinPoint.execute(links.stream().map(wl -> wl.getToStepNo()).toList(), stepMap, linkMap); + return (joinStepNo); + } ////////////////////////////////////////////////////////////////////////////////////// // look for a link between the fromStepNo matching the stepOutput / condition value // ////////////////////////////////////////////////////////////////////////////////////// + List evaluatedConditionValues = new ArrayList<>(); for(WorkflowLink link : CollectionUtils.nonNullList(links)) { if(link.getConditionValue() == null) @@ -350,8 +386,10 @@ public class WorkflowExecutor extends AbstractQActionBiConsumer stepOutputClass = stepOutput == null ? null : stepOutput.getClass(); Serializable valueAsType = stepOutputClass == null ? link.getConditionValue() : ValueUtils.getValueAsType(stepOutputClass, link.getConditionValue()); if(Objects.equals(valueAsType, stepOutput)) { @@ -365,6 +403,11 @@ public class WorkflowExecutor extends AbstractQActionBiConsumer mapValues = jsonObject.toMap(); @@ -415,13 +457,23 @@ public class WorkflowExecutor extends AbstractQActionBiConsumer. + */ + +package com.kingsrook.qbits.workflows.execution; + + +import java.io.Serializable; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import com.kingsrook.qbits.workflows.model.WorkflowLink; +import com.kingsrook.qbits.workflows.model.WorkflowRunLogStep; +import com.kingsrook.qbits.workflows.model.WorkflowStep; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.utils.ValueUtils; +import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; + + +/*************************************************************************** + * interface for workflow step executors that are of a variable output type, + * and which can execute 0 or more of their outbound links. + ***************************************************************************/ +public interface WorkflowMultiForkingStepExecutorInterface extends WorkflowStepExecutorInterface +{ + QLogger LOG = QLogger.getLogger(WorkflowMultiForkingStepExecutorInterface.class); + + /*************************************************************************** + * the Execute method for such steps should perform an executor.runStepLoop + * for each of the outbound links of the step (assuming they are supposed + * allowed to run). + ***************************************************************************/ + @Override + default WorkflowStepOutput execute(WorkflowStep step, Map inputValues, WorkflowExecutionContext context) throws QException + { + /////////////////////////////////////////////////////////////////////// + // get the various sub-workflows ("fork links") to run from the step // + /////////////////////////////////////////////////////////////////////// + WorkflowExecutor executor = context.getExecutor(); + ExecutionPayload payload = executor.getPayload(); + + List forkLinks = payload.linkMap().get(step.getStepNo()); + preRun(step, inputValues, context); + sortForks(forkLinks, step, inputValues, context); + + Integer stopStepNo = FindFirstJoinPoint.execute(forkLinks.stream().map(wl -> wl.getToStepNo()).toList(), payload.stepMap(), payload.linkMap()); + boolean shouldWorkflowImmediatelyStop = false; + for(WorkflowLink link : forkLinks) + { + WorkflowRunLogStep workflowRunLogStep = new WorkflowRunLogStep(); + workflowRunLogStep.setWorkflowStepId(step.getId()); + workflowRunLogStep.setSeqNo(payload.seqNo().getAndIncrement()); + workflowRunLogStep.setStartTimestamp(Instant.now()); + + WorkflowStepOutput workflowStepOutput = conditionallyExecuteFork(step, link, inputValues, context); + if(Objects.equals(workflowStepOutput.outputData(), true)) + { + shouldWorkflowImmediatelyStop = executor.runStepLoop(link.getToStepNo(), context, stopStepNo); + } + + workflowRunLogStep.setOutputData(ValueUtils.getValueAsString(workflowStepOutput.outputData())); + workflowRunLogStep.setMessage(workflowStepOutput.message()); + workflowRunLogStep.setEndTimestamp(Instant.now()); + payload.logStepList().add(workflowRunLogStep); + + if(shouldWorkflowImmediatelyStop) + { + LOG.info("Stopping fork-link loop, due to step output indicating workflow should immediately stop", logPair("linkStartStepNo", link.getToStepNo())); + break; + } + } + + WorkflowStepOutput workflowStepOutput = makeWorkflowStepOutput(step, inputValues, context); + postRun(step, inputValues, context); + + if(shouldWorkflowImmediatelyStop) + { + workflowStepOutput = new WorkflowStepOutput(workflowStepOutput.outputData(), workflowStepOutput.message(), true); + } + + return workflowStepOutput; + } + + /*************************************************************************** + * make sure the forks are executed in the order that the step expects. + ***************************************************************************/ + void sortForks(List forkLinks, WorkflowStep step, Map inputValues, WorkflowExecutionContext context); + + /*************************************************************************** + * decide if a particular fork should be executed - and if so, then do so. + ***************************************************************************/ + WorkflowStepOutput conditionallyExecuteFork(WorkflowStep step, WorkflowLink fork, Map inputValues, WorkflowExecutionContext context) throws QException; + + /*************************************************************************** + * make output for the entire step, e.g., taking into account all of its + * forks. + ***************************************************************************/ + WorkflowStepOutput makeWorkflowStepOutput(WorkflowStep step, Map inputValues, WorkflowExecutionContext context) throws QException; + + /*************************************************************************** + * optional code to run before anything else in this interface + ***************************************************************************/ + default void preRun(WorkflowStep step, Map inputValues, WorkflowExecutionContext context) throws QException + { + + } + + /*************************************************************************** + * optional code to run after anything else in this interface + ***************************************************************************/ + default void postRun(WorkflowStep step, Map inputValues, WorkflowExecutionContext context) throws QException + { + + } +} diff --git a/src/main/java/com/kingsrook/qbits/workflows/execution/WorkflowStepOutput.java b/src/main/java/com/kingsrook/qbits/workflows/execution/WorkflowStepOutput.java index 731e074..e421c24 100644 --- a/src/main/java/com/kingsrook/qbits/workflows/execution/WorkflowStepOutput.java +++ b/src/main/java/com/kingsrook/qbits/workflows/execution/WorkflowStepOutput.java @@ -28,16 +28,26 @@ import java.io.Serializable; /*************************************************************************** ** ***************************************************************************/ -public record WorkflowStepOutput(Serializable outputData, String message) +public record WorkflowStepOutput(Serializable outputData, String message, boolean shouldWorkflowImmediatelyStop) { + /******************************************************************************* + ** Constructor + ** + *******************************************************************************/ + public WorkflowStepOutput(Serializable outputData, String message) + { + this(outputData, message, false); + } + + /******************************************************************************* ** Constructor ** *******************************************************************************/ public WorkflowStepOutput(Serializable outputData) { - this(outputData, null); + this(outputData, null, false); } @@ -48,7 +58,7 @@ public record WorkflowStepOutput(Serializable outputData, String message) *******************************************************************************/ public WorkflowStepOutput() { - this(null, null); + this(null, null, false); } } diff --git a/src/main/java/com/kingsrook/qbits/workflows/execution/WorkflowTypeValidatorInterface.java b/src/main/java/com/kingsrook/qbits/workflows/execution/WorkflowTypeValidatorInterface.java new file mode 100644 index 0000000..b7845d3 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/workflows/execution/WorkflowTypeValidatorInterface.java @@ -0,0 +1,51 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qbits.workflows.execution; + + +import java.util.List; +import com.kingsrook.qbits.workflows.model.WorkflowLink; +import com.kingsrook.qbits.workflows.model.WorkflowStep; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.data.QRecord; + + +/******************************************************************************* + * interface for the code that validates the overall structure of a workflow, + * e.g., when it's being saved. This would be where you'd validate relationships + * between steps (e.g., this step must have some other step within it) + *******************************************************************************/ +public interface WorkflowTypeValidatorInterface +{ + + /*************************************************************************** + * validate that a workflow can be saved. + * + * @param workflow the workflow record under which a new revision is being saved + * @param workflowRevision the new revision record that's been built + * @param workflowSteps list of workflow steps being saved + * @param workflowLinks list of workflow links being saved + * @param errors out param - any validation errors should be added to this list. + ***************************************************************************/ + void validate(QRecord workflow, QRecord workflowRevision, List workflowSteps, List workflowLinks, List errors) throws QException; + +} diff --git a/src/main/java/com/kingsrook/qbits/workflows/execution/nodes/NodeSequence.java b/src/main/java/com/kingsrook/qbits/workflows/execution/nodes/NodeSequence.java new file mode 100644 index 0000000..6d5c20f --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/workflows/execution/nodes/NodeSequence.java @@ -0,0 +1,72 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qbits.workflows.execution.nodes; + + +import java.util.ArrayList; +import java.util.List; + + +/*************************************************************************** + * list of nodes in a graph representation of a workflow + ***************************************************************************/ +public record NodeSequence(List nodes) +{ + /******************************************************************************* + ** Constructor + ** + *******************************************************************************/ + public NodeSequence() + { + this(new ArrayList<>()); + } + + + + /*************************************************************************** + * + ***************************************************************************/ + public void add(StepNode node) + { + nodes.add(node); + } + + + + /*************************************************************************** + * + ***************************************************************************/ + public StepNode get(int i) + { + return nodes.get(i); + } + + + + /*************************************************************************** + * + ***************************************************************************/ + public int size() + { + return (nodes.size()); + } +} diff --git a/src/main/java/com/kingsrook/qbits/workflows/execution/nodes/StepNode.java b/src/main/java/com/kingsrook/qbits/workflows/execution/nodes/StepNode.java new file mode 100644 index 0000000..2a9c58a --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/workflows/execution/nodes/StepNode.java @@ -0,0 +1,47 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qbits.workflows.execution.nodes; + + +import java.util.HashMap; +import java.util.Map; +import com.kingsrook.qbits.workflows.model.WorkflowStep; + + +/*************************************************************************** + * Single step, plus, if it opens a nested scope (e.g., branches, or a container) + * then those subSequences, along with their conditionalValues (guards/labels) + * as map keys + ***************************************************************************/ +public record StepNode(WorkflowStep step, Map subSequences) +{ + + /******************************************************************************* + ** Constructor for step w/o subSequences + ** + *******************************************************************************/ + public StepNode(WorkflowStep step) + { + this(step, new HashMap<>()); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/workflows/execution/nodes/StepNodeGraphBuilder.java b/src/main/java/com/kingsrook/qbits/workflows/execution/nodes/StepNodeGraphBuilder.java new file mode 100644 index 0000000..fd96bd4 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/workflows/execution/nodes/StepNodeGraphBuilder.java @@ -0,0 +1,224 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qbits.workflows.execution.nodes; + + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import com.kingsrook.qbits.workflows.definition.OutboundLinkMode; +import com.kingsrook.qbits.workflows.definition.WorkflowStepType; +import com.kingsrook.qbits.workflows.definition.WorkflowsRegistry; +import com.kingsrook.qbits.workflows.execution.FindFirstJoinPoint; +import com.kingsrook.qbits.workflows.model.WorkflowLink; +import com.kingsrook.qbits.workflows.model.WorkflowStep; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.utils.CollectionUtils; +import com.kingsrook.qqq.backend.core.utils.ListingHash; +import com.kingsrook.qqq.backend.core.utils.StringUtils; + + +/******************************************************************************* + * Class that can convert a list of steps & links (as a workflow revision is + * stored in a database) to a hierarchical graph data structure - a list of nodes, + * each of which is a step, and optionally a map of lists of sub-branches + * (which can be more useful for various kinds of processing). + *******************************************************************************/ +public class StepNodeGraphBuilder +{ + private static final QLogger LOG = QLogger.getLogger(StepNodeGraphBuilder.class); + + + + /*************************************************************************** + * + ***************************************************************************/ + public static NodeSequence buildNodeSequence(Integer startStepNo, List workflowSteps, List workflowLinks) + { + Map stepMap = CollectionUtils.listToMap(workflowSteps, ws -> ws.getStepNo()); + ListingHash linksFromMap = new ListingHash<>(); + Map> linksToMap = new HashMap<>(); + + for(WorkflowLink workflowLink : workflowLinks) + { + linksFromMap.add(workflowLink.getFromStepNo(), workflowLink); + + if(workflowLink.getToStepNo() != null) + { + Set subToSet = linksToMap.computeIfAbsent(workflowLink.getToStepNo(), k -> new HashSet<>()); + subToSet.add(workflowLink.getFromStepNo()); + } + } + + NodeSequence nodeSequence = new NodeSequence(); + buildNodeSequence(nodeSequence, stepMap.get(startStepNo), stepMap, linksFromMap, null, 0); + return (nodeSequence); + } + + + + /*************************************************************************** + * + ***************************************************************************/ + private static void buildNodeSequence(NodeSequence nodeSequence, WorkflowStep startStep, Map stepMap, ListingHash linksFromMap, Integer joinStepNo, Integer depth) + { + WorkflowsRegistry workflowsRegistry = WorkflowsRegistry.of(QContext.getQInstance()); + + WorkflowStep currentWorkflowStep = startStep; + while(currentWorkflowStep != null) + { + WorkflowStepType workflowStepType = workflowsRegistry.getWorkflowStepType(currentWorkflowStep.getWorkflowStepTypeName()); + if(workflowStepType == null) + { + LOG.warn("Unrecognized workflow step type: " + currentWorkflowStep.getWorkflowStepTypeName()); + return; + } + + if(Objects.equals(currentWorkflowStep.getStepNo(), joinStepNo)) + { + ////////////////////////////////////////////////////////////////////////////// + // if we've reached the join step, it's the end of this sequence, so return // + ////////////////////////////////////////////////////////////////////////////// + return; + } + + StepNode stepNode = new StepNode(currentWorkflowStep); + nodeSequence.add(stepNode); + + List outboundLinks = linksFromMap.get(currentWorkflowStep.getStepNo()); + if(CollectionUtils.nullSafeIsEmpty(outboundLinks)) + { + ///////////////////////////////////////////////////////////////////////////// + // if no outbound links, then we're at the end of this sequence, so return // + ///////////////////////////////////////////////////////////////////////////// + return; + } + else if(outboundLinks.size() == 1 && !StringUtils.hasContent(outboundLinks.get(0).getConditionValue())) // ?? avoid being tripped by a branch w/ only 1 side populated at the end of the graph + { + /////////////////////////////////////////////////////////////////// + // 1 outbound link here means we're not in a switch/if-else/fork // + /////////////////////////////////////////////////////////////////// + WorkflowLink outboundLink = outboundLinks.get(0); + currentWorkflowStep = outboundLink.getToStepNo() == null ? null : stepMap.get(outboundLink.getToStepNo()); + + //////////////////////////////// + // todo is this == null okay? // + //////////////////////////////// + if(currentWorkflowStep == null || currentWorkflowStep.getStepNo().equals(joinStepNo)) + { + /////////////////////////////////////////////////////// + // this means we're ending a nested (recursive) call // + /////////////////////////////////////////////////////// + return; + } + } + else + { + if(workflowStepType.getOutboundLinkMode().equals(OutboundLinkMode.TWO) || workflowStepType.getOutboundLinkMode().equals(OutboundLinkMode.VARIABLE)) + { + /////////////////////////////////////////////////////////////////////////////////////////////////// + // this means we are starting a fork. find where it ends - where the branches first re-converge // + /////////////////////////////////////////////////////////////////////////////////////////////////// + Integer branchJoin = FindFirstJoinPoint.executeForLinks(outboundLinks, stepMap, linksFromMap); + + //////////////////////////////////////////// + // for each branch, make a recursive call // + //////////////////////////////////////////// + for(WorkflowLink outboundLink : outboundLinks) + { + String conditionValue = outboundLink.getConditionValue(); + NodeSequence branchSequence = new NodeSequence(); + stepNode.subSequences().put(conditionValue, branchSequence); + + if(branchJoin != null && Objects.equals(branchJoin, outboundLink.getToStepNo())) + { + //////////////////////////////////////////////////////////////////////// + // this indicates an empty branch - so continue to next outbound link // + //////////////////////////////////////////////////////////////////////// + continue; + } + + /////////////////////////////////////////////////////////////////////////////////////////// + // if this is a step out of the branch (e.g., to a join), then don't process it in here. // + // also though, this line doesn't actually get hit, so... // + /////////////////////////////////////////////////////////////////////////////////////////// + if(Objects.equals(outboundLink.getToStepNo(), joinStepNo)) + { + continue; + } + else + { + if(outboundLink.getToStepNo() != null) + { + buildNodeSequence(branchSequence, stepMap.get(outboundLink.getToStepNo()), stepMap, linksFromMap, branchJoin, depth + 1); + } + } + } + + //////////////////////////////////////////////////////////////////////////////// + // after handling all the branches, then continue the loop with the join step // + //////////////////////////////////////////////////////////////////////////////// + currentWorkflowStep = branchJoin == null ? null : stepMap.get(branchJoin); + } + else if(workflowStepType.getOutboundLinkMode().equals(OutboundLinkMode.CONTAINER)) + { + /////////////////////////////////////////////////////////////////////// + // if the container is non-empty, there should be a push link for it // + /////////////////////////////////////////////////////////////////////// + Optional pushLink = outboundLinks.stream().filter(ol -> "push".equals(ol.getConditionValue())).findFirst(); + if(pushLink.isPresent()) + { + if(pushLink.get().getToStepNo() != null) + { + NodeSequence containerSequence = new NodeSequence(); + stepNode.subSequences().put("push", containerSequence); + buildNodeSequence(containerSequence, stepMap.get(pushLink.get().getToStepNo()), stepMap, linksFromMap, null, depth + 1); + } + } + + Optional popLink = outboundLinks.stream().filter(ol -> "pop".equals(ol.getConditionValue())).findFirst(); + if(popLink.isPresent()) + { + if(popLink.get().getToStepNo() != null) + { + currentWorkflowStep = stepMap.get(popLink.get().getToStepNo()); + } + } + else + { + return; + } + } + else + { + LOG.warn("expected to have multiple outbound links for a step of type: " + currentWorkflowStep.getWorkflowStepTypeName()); + } + } + } + } + +} diff --git a/src/main/java/com/kingsrook/qbits/workflows/model/WorkflowLink.java b/src/main/java/com/kingsrook/qbits/workflows/model/WorkflowLink.java index c557b41..ffc6595 100644 --- a/src/main/java/com/kingsrook/qbits/workflows/model/WorkflowLink.java +++ b/src/main/java/com/kingsrook/qbits/workflows/model/WorkflowLink.java @@ -84,7 +84,7 @@ public class WorkflowLink extends QRecordEntity @QField(isRequired = true) private Integer fromStepNo; - @QField(isRequired = true) + @QField() private Integer toStepNo; @QField(maxLength = 20, valueTooLongBehavior = ValueTooLongBehavior.ERROR) diff --git a/src/main/java/com/kingsrook/qbits/workflows/processes/GetWorkflowStepDynamicValuesProcess.java b/src/main/java/com/kingsrook/qbits/workflows/processes/GetWorkflowStepDynamicValuesProcess.java new file mode 100644 index 0000000..ca82675 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/workflows/processes/GetWorkflowStepDynamicValuesProcess.java @@ -0,0 +1,116 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qbits.workflows.processes; + + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import com.google.gson.reflect.TypeToken; +import com.kingsrook.qbits.workflows.definition.OutboundLinkMode; +import com.kingsrook.qbits.workflows.definition.OutboundLinkOption; +import com.kingsrook.qbits.workflows.definition.WorkflowStepType; +import com.kingsrook.qbits.workflows.definition.WorkflowsRegistry; +import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.utils.CollectionUtils; +import com.kingsrook.qqq.backend.core.utils.ValueUtils; + + +/******************************************************************************* + * process to get values that the backend must compute about a workflow step, + * based on input values, e.g., as a user is editing the step in a UI. + * + * Originally this was just the step summary (e.g., workflowStepType.getDynamicStepSummary) + * + * Then, when variable outboundLinkModes were added, also responsible for returning + * the link options to use for a step, based on its input values. + * + *******************************************************************************/ +public class GetWorkflowStepDynamicValuesProcess implements BackendStep, MetaDataProducerInterface +{ + public static final String NAME = "getWorkflowStepDynamicValues"; + + + + /*************************************************************************** + ** + ***************************************************************************/ + @Override + public QProcessMetaData produce(QInstance qInstance) throws QException + { + return new QProcessMetaData() + .withName(NAME) + .withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED)) + .withStep(new QBackendStepMetaData() + .withName("execute") + .withCode(new QCodeReference(getClass())) + .withInputData(new QFunctionInputMetaData() + .withField(new QFieldMetaData("workflowId", QFieldType.INTEGER)) + .withField(new QFieldMetaData("workflowStepTypeName", QFieldType.STRING)) + .withField(new QFieldMetaData("values", QFieldType.INTEGER)) // JSON + )); + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + @Override + public void run(RunBackendStepInput runBackendStepInput, RunBackendStepOutput runBackendStepOutput) throws QException + { + Integer workflowId = runBackendStepInput.getValueInteger("workflowId"); + + String workflowStepTypeName = runBackendStepInput.getValueString("workflowStepTypeName"); + String values = runBackendStepInput.getValueString("values"); + Map valueAsMap = ValueUtils.getValueAsMap(values); + + WorkflowStepType workflowStepType = WorkflowsRegistry.of(QContext.getQInstance()).getWorkflowStepType(workflowStepTypeName); + runBackendStepOutput.addValue("summary", workflowStepType.getDynamicStepSummary(workflowId, valueAsMap)); + + if(OutboundLinkMode.VARIABLE.equals(workflowStepType.getOutboundLinkMode())) + { + List dynamicOutboundLinkOptions = workflowStepType.getDynamicOutboundLinkOptions(workflowId, valueAsMap); + if(dynamicOutboundLinkOptions != null) + { + ArrayList outboundLinkOptions = CollectionUtils.useOrWrap(dynamicOutboundLinkOptions, new TypeToken<>() {}); + runBackendStepOutput.addValue("outboundLinkOptions", outboundLinkOptions); + } + } + } + +} diff --git a/src/main/java/com/kingsrook/qbits/workflows/processes/GetWorkflowStepSummaryProcess.java b/src/main/java/com/kingsrook/qbits/workflows/processes/GetWorkflowStepSummaryProcess.java index b3b9928..95b78ba 100644 --- a/src/main/java/com/kingsrook/qbits/workflows/processes/GetWorkflowStepSummaryProcess.java +++ b/src/main/java/com/kingsrook/qbits/workflows/processes/GetWorkflowStepSummaryProcess.java @@ -48,6 +48,7 @@ import com.kingsrook.qqq.backend.core.utils.ValueUtils; ** process to get a dynamic summary of a workflow step, e.g., as it's being ** edited in a UI. *******************************************************************************/ +@Deprecated(since = "0.115.0 - November 2025 - Replaced by GetWorkflowStepDynamicValuesProcess") public class GetWorkflowStepSummaryProcess implements BackendStep, MetaDataProducerInterface { public static final String NAME = "getWorkflowStepSummary"; @@ -58,7 +59,6 @@ public class GetWorkflowStepSummaryProcess implements BackendStep, MetaDataProdu ** ***************************************************************************/ @Override - public QProcessMetaData produce(QInstance qInstance) throws QException { return new QProcessMetaData() diff --git a/src/main/java/com/kingsrook/qbits/workflows/processes/StoreNewWorkflowRevisionProcess.java b/src/main/java/com/kingsrook/qbits/workflows/processes/StoreNewWorkflowRevisionProcess.java index 9c4c7e9..6da1218 100644 --- a/src/main/java/com/kingsrook/qbits/workflows/processes/StoreNewWorkflowRevisionProcess.java +++ b/src/main/java/com/kingsrook/qbits/workflows/processes/StoreNewWorkflowRevisionProcess.java @@ -29,8 +29,10 @@ import java.util.Map; import java.util.Objects; import com.fasterxml.jackson.core.type.TypeReference; import com.kingsrook.qbits.workflows.definition.WorkflowStepType; +import com.kingsrook.qbits.workflows.definition.WorkflowType; import com.kingsrook.qbits.workflows.definition.WorkflowsRegistry; import com.kingsrook.qbits.workflows.execution.WorkflowStepValidatorInterface; +import com.kingsrook.qbits.workflows.execution.WorkflowTypeValidatorInterface; import com.kingsrook.qbits.workflows.model.Workflow; import com.kingsrook.qbits.workflows.model.WorkflowLink; import com.kingsrook.qbits.workflows.model.WorkflowRevision; @@ -291,9 +293,19 @@ public class StoreNewWorkflowRevisionProcess implements BackendStep, MetaDataPro } }); + //////////////////////////////////////////////////////////// + // do workflow-level validation as well, if so configured // + //////////////////////////////////////////////////////////// + WorkflowType workflowType = WorkflowsRegistry.of(QContext.getQInstance()).getWorkflowType(workflowRecord.getValueString("workflowTypeName")); + if(workflowType.getValidator() != null) + { + WorkflowTypeValidatorInterface validator = QCodeLoader.getAdHoc(WorkflowTypeValidatorInterface.class, workflowType.getValidator()); + validator.validate(workflowRecord, workflowRevisionRecord, workflowSteps, workflowLinks, errors); + } + if(!errors.isEmpty()) { - String message = errors.size() + " validation error" + StringUtils.plural(errors) + " occurred within the workflow's steps:\n" + StringUtils.join("\n", errors); + String message = errors.size() + " validation error" + StringUtils.plural(errors) + " occurred before the workflow could be saved:\n" + StringUtils.join("\n", errors); throw (new QUserFacingException(message)); } diff --git a/src/test/java/com/kingsrook/qbits/workflows/TestWorkflowDefinitions.java b/src/test/java/com/kingsrook/qbits/workflows/TestWorkflowDefinitions.java index 870d839..de97f9a 100644 --- a/src/test/java/com/kingsrook/qbits/workflows/TestWorkflowDefinitions.java +++ b/src/test/java/com/kingsrook/qbits/workflows/TestWorkflowDefinitions.java @@ -32,11 +32,13 @@ import com.kingsrook.qbits.workflows.definition.WorkflowType; import com.kingsrook.qbits.workflows.definition.WorkflowsRegistry; import com.kingsrook.qbits.workflows.execution.WorkflowExecutionContext; import com.kingsrook.qbits.workflows.execution.WorkflowInput; +import com.kingsrook.qbits.workflows.execution.WorkflowMultiForkingStepExecutorInterface; import com.kingsrook.qbits.workflows.execution.WorkflowStepExecutorInterface; import com.kingsrook.qbits.workflows.execution.WorkflowStepOutput; import com.kingsrook.qbits.workflows.execution.WorkflowTypeExecutorInterface; import com.kingsrook.qbits.workflows.execution.WorkflowTypeTesterInterface; import com.kingsrook.qbits.workflows.model.Workflow; +import com.kingsrook.qbits.workflows.model.WorkflowLink; import com.kingsrook.qbits.workflows.model.WorkflowRevision; import com.kingsrook.qbits.workflows.model.WorkflowStep; import com.kingsrook.qqq.backend.core.context.QContext; @@ -56,6 +58,8 @@ public class TestWorkflowDefinitions public static final String BOOLEAN_CONDITIONAL = "booleanConditional"; public static final String CONTAINER = "container"; + public static final String SPLIT_BY_LETTERS_IN_NAME = "splitByLettersInName"; + /*************************************************************************** @@ -93,6 +97,12 @@ public class TestWorkflowDefinitions .withOutboundLinkMode(OutboundLinkMode.CONTAINER) .withLabel("Container") .withDescription("Group steps in the UI")); + + WorkflowsRegistry.of(QContext.getQInstance()).registerWorkflowStepType(new WorkflowStepType() + .withName(SPLIT_BY_LETTERS_IN_NAME) + .withOutboundLinkMode(OutboundLinkMode.VARIABLE) + .withLabel("Split by letters in name") + .withExecutor(new QCodeReference(SplitByLettersInNameExecutor.class))); } @@ -225,4 +235,57 @@ public class TestWorkflowDefinitions } } + + + /*************************************************************************** + * + ***************************************************************************/ + public static class SplitByLettersInNameExecutor implements WorkflowMultiForkingStepExecutorInterface + { + + /*************************************************************************** + * + ***************************************************************************/ + @Override + public WorkflowStepOutput conditionallyExecuteFork(WorkflowStep step, WorkflowLink fork, Map inputValues, WorkflowExecutionContext context) + { + String conditionValue = fork.getConditionValue(); + String name = ValueUtils.getValueAsString(context.getValues().get("name")); + + if(name.contains(conditionValue)) + { + return (new WorkflowStepOutput(true, "name [" + name + "] contains [" + conditionValue + "]")); + } + else + { + return (new WorkflowStepOutput(false, "name [" + name + "] does not contain [" + conditionValue + "]")); + } + } + + + + /*************************************************************************** + * + ***************************************************************************/ + @Override + public WorkflowStepOutput makeWorkflowStepOutput(WorkflowStep step, Map inputValues, WorkflowExecutionContext context) throws QException + { + return new WorkflowStepOutput(1, "Output"); + } + + + + /*************************************************************************** + * + ***************************************************************************/ + @Override + public void sortForks(List forkLinks, WorkflowStep step, Map inputValues, WorkflowExecutionContext context) + { + ///////////////////// + // noop by default // + ///////////////////// + } + + } + } diff --git a/src/test/java/com/kingsrook/qbits/workflows/WorkflowsTestDataSource.java b/src/test/java/com/kingsrook/qbits/workflows/WorkflowsTestDataSource.java index 5ffdad8..9187832 100644 --- a/src/test/java/com/kingsrook/qbits/workflows/WorkflowsTestDataSource.java +++ b/src/test/java/com/kingsrook/qbits/workflows/WorkflowsTestDataSource.java @@ -27,7 +27,6 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.UUID; -import com.kingsrook.qbits.workflows.implementations.recordworkflows.RecordWorkflowsDefinition; import com.kingsrook.qbits.workflows.model.Workflow; import com.kingsrook.qbits.workflows.model.WorkflowLink; import com.kingsrook.qbits.workflows.model.WorkflowRevision; @@ -149,7 +148,7 @@ public class WorkflowsTestDataSource public static Workflow insertWorkflowAndInitialRevision(String workflowTypeName, String tableName, String apiName, String apiVersion) throws QException { QRecord workflow = new InsertAction().execute(new InsertInput(Workflow.TABLE_NAME).withRecordEntity(new Workflow() - .withWorkflowTypeName(RecordWorkflowsDefinition.WORKFLOW_TYPE) + .withWorkflowTypeName(workflowTypeName) .withTableName(tableName) .withName("test-" + UUID.randomUUID()) )).getRecords().get(0); diff --git a/src/test/java/com/kingsrook/qbits/workflows/execution/FindFirstJoinPointTest.java b/src/test/java/com/kingsrook/qbits/workflows/execution/FindFirstJoinPointTest.java new file mode 100644 index 0000000..19ba1b9 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/workflows/execution/FindFirstJoinPointTest.java @@ -0,0 +1,130 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qbits.workflows.execution; + + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.workflows.BaseTest; +import com.kingsrook.qbits.workflows.model.WorkflowLink; +import com.kingsrook.qbits.workflows.model.WorkflowStep; +import com.kingsrook.qqq.backend.core.utils.ListingHash; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + + +/******************************************************************************* + ** Unit test for FindFirstJoinPoint + *******************************************************************************/ +class FindFirstJoinPointTest extends BaseTest +{ + + /******************************************************************************* + * 1 + * / | \ + * / | \ + * 2 3 4 + * | | / \ + * | 5 6 7 + * | | | | + * | | | 8 + * | | \ / + * | | 9 + * \ | / + * 10 + *******************************************************************************/ + @Test + void test() + { + Map nodeMap = makeNodeMap(10); + + ListingHash outEdges = new ListingHash<>(); + addEdge(outEdges, 1, 2); + addEdge(outEdges, 1, 3); + addEdge(outEdges, 1, 4); + addEdge(outEdges, 2, 10); + addEdge(outEdges, 3, 5); + addEdge(outEdges, 4, 6); + addEdge(outEdges, 5, 10); + addEdge(outEdges, 6, 9); + addEdge(outEdges, 7, 8); + addEdge(outEdges, 8, 9); + addEdge(outEdges, 9, 10); + + ///////////////////////////////// + // start from the top (node 1) // + ///////////////////////////////// + assertEquals(10, FindFirstJoinPoint.execute(List.of(2, 3, 4), nodeMap, outEdges)); + + //////////////////////////////////////////// + // start from the rightmost fork (node 4) // + //////////////////////////////////////////// + assertEquals(9, FindFirstJoinPoint.execute(List.of(6, 7), nodeMap, outEdges)); + + ////////////////////////////////////////////////////////////////////////////////// + // remove node 10 - so they all just end and don't re-join (so expect null out) // + ////////////////////////////////////////////////////////////////////////////////// + nodeMap.remove(10); + outEdges.remove(9); + outEdges.remove(2); + outEdges.remove(5); + assertNull(FindFirstJoinPoint.execute(List.of(2, 3, 4), nodeMap, outEdges)); + } + + + + /*************************************************************************** + * + ***************************************************************************/ + private void addEdge(ListingHash outEdges, Integer fromStepNo, Integer toStepNo) + { + outEdges.add(fromStepNo, new WorkflowLink().withToStepNo(toStepNo)); + } + + + + /*************************************************************************** + * + ***************************************************************************/ + private Map makeNodeMap(int n) + { + Map nodeMap = new HashMap<>(); + for(int i = 1; i <= n; i++) + { + addNode(nodeMap, i); + } + return (nodeMap); + } + + + + /*************************************************************************** + * + ***************************************************************************/ + private void addNode(Map nodeMap, Integer stepNo) + { + nodeMap.put(stepNo, new WorkflowStep().withStepNo(stepNo)); + } + +} \ No newline at end of file diff --git a/src/test/java/com/kingsrook/qbits/workflows/execution/WorkflowExecutorTest.java b/src/test/java/com/kingsrook/qbits/workflows/execution/WorkflowExecutorTest.java index 8c13c95..7218445 100644 --- a/src/test/java/com/kingsrook/qbits/workflows/execution/WorkflowExecutorTest.java +++ b/src/test/java/com/kingsrook/qbits/workflows/execution/WorkflowExecutorTest.java @@ -29,8 +29,16 @@ import com.kingsrook.qbits.workflows.BaseTest; import com.kingsrook.qbits.workflows.TestWorkflowDefinitions; import com.kingsrook.qbits.workflows.WorkflowsTestDataSource; import com.kingsrook.qbits.workflows.model.Workflow; +import com.kingsrook.qbits.workflows.model.WorkflowLink; +import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction; import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.utils.ValueUtils; import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder; +import com.kingsrook.qqq.backend.core.utils.lambdas.UnsafeFunction; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -301,6 +309,183 @@ class WorkflowExecutorTest extends BaseTest + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testMultiForkingStep() throws Exception + { + TestWorkflowDefinitions.registerTestWorkflowTypes(); + + /////////////////////////////////// + // build a workflow with 3 forks // + // . 1 // + // ./ | \ // + // 2 3 4 // + /////////////////////////////////// + Workflow workflow = WorkflowsTestDataSource.insertWorkflowAndInitialRevision(TestWorkflowDefinitions.TEST_WORKFLOW_TYPE, null); + WorkflowsTestDataSource.insertSteps(workflow, List.of( + WorkflowsTestDataSource.newStep(1, TestWorkflowDefinitions.SPLIT_BY_LETTERS_IN_NAME, Map.of()), + WorkflowsTestDataSource.newStep(2, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 1)), // "a" + WorkflowsTestDataSource.newStep(3, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 2)), // "b" + WorkflowsTestDataSource.newStep(4, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 4)) // "c" + )); + WorkflowsTestDataSource.insertLinks(workflow, List.of( + WorkflowsTestDataSource.newLink(1, 2, "a"), // += 1 + WorkflowsTestDataSource.newLink(1, 3, "b"), // += 2 + WorkflowsTestDataSource.newLink(1, 4, "c"), // += 3 + WorkflowsTestDataSource.newLink(2, null), + WorkflowsTestDataSource.newLink(3, null), + WorkflowsTestDataSource.newLink(4, null) + )); + + UnsafeFunction run = (String name) -> + { + WorkflowOutput output = executeWorkflow(workflow.getId(), Map.of("seedValue", 0, "name", name)); + return ValueUtils.getValueAsInteger(output.getContext().getValues().get("sum")); + }; + + assertEquals(1, run.apply("dave")); + assertEquals(3, run.apply("barry")); + assertEquals(5, run.apply("carl")); + assertEquals(7, run.apply("braces")); + assertEquals(0, run.apply("lou")); + + ////////////////////////////////////// + // add a step (5) after the 3 forks // + ////////////////////////////////////// + WorkflowsTestDataSource.insertSteps(workflow, List.of( + WorkflowsTestDataSource.newStep(5, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 8)) + )); + WorkflowsTestDataSource.insertLinks(workflow, List.of( + WorkflowsTestDataSource.newLink(2, 5), + WorkflowsTestDataSource.newLink(3, 5), + WorkflowsTestDataSource.newLink(4, 5) + )); + + ////////////////////////////////// + // get rid of the links to null // + ////////////////////////////////// + new DeleteAction().execute(new DeleteInput(WorkflowLink.TABLE_NAME).withQueryFilter(new QQueryFilter(new QFilterCriteria("toStepNo", QCriteriaOperator.IS_BLANK)))); + + assertEquals(10, run.apply("bobby")); + assertEquals(15, run.apply("braces")); + assertEquals(8, run.apply("lou")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testNestedMultiForkingStep() throws Exception + { + TestWorkflowDefinitions.registerTestWorkflowTypes(); + + ///////////////////////////////////////////////////////////////////////////// + // build a workflow with 3 top-level forks, one of which has 2 nested ones // + // . 1 // + // ./ | \ // + // 2 3 4 // + // | | | \ // + // | | 5 6 // + // .\ \ / / // + // . 7 // + ///////////////////////////////////////////////////////////////////////////// + Workflow workflow = WorkflowsTestDataSource.insertWorkflowAndInitialRevision(TestWorkflowDefinitions.TEST_WORKFLOW_TYPE, null); + WorkflowsTestDataSource.insertSteps(workflow, List.of( + WorkflowsTestDataSource.newStep(1, TestWorkflowDefinitions.SPLIT_BY_LETTERS_IN_NAME, Map.of()), + WorkflowsTestDataSource.newStep(2, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 1)), // "a" + WorkflowsTestDataSource.newStep(3, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 2)), // "b" + WorkflowsTestDataSource.newStep(4, TestWorkflowDefinitions.SPLIT_BY_LETTERS_IN_NAME, Map.of()), + WorkflowsTestDataSource.newStep(5, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 4)), // "c" + WorkflowsTestDataSource.newStep(6, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 8)), // "d" + WorkflowsTestDataSource.newStep(7, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 16)) + )); + WorkflowsTestDataSource.insertLinks(workflow, List.of( + WorkflowsTestDataSource.newLink(1, 2, "a"), // += 1 + WorkflowsTestDataSource.newLink(1, 3, "b"), // += 2 + WorkflowsTestDataSource.newLink(1, 4, ""), // + WorkflowsTestDataSource.newLink(4, 5, "c"), // += 4 + WorkflowsTestDataSource.newLink(4, 6, "d"), // += 8 + WorkflowsTestDataSource.newLink(2, 7), // += 16 + WorkflowsTestDataSource.newLink(3, 7), // += 16 + WorkflowsTestDataSource.newLink(5, 7), // += 16 + WorkflowsTestDataSource.newLink(6, 7) // += 16 + )); + + UnsafeFunction run = (String name) -> + { + WorkflowOutput output = executeWorkflow(workflow.getId(), Map.of("seedValue", 0, "name", name)); + return ValueUtils.getValueAsInteger(output.getContext().getValues().get("sum")); + }; + + assertEquals(17, run.apply("allen")); + assertEquals(19, run.apply("barry")); + assertEquals(21, run.apply("carl")); + assertEquals(24, run.apply("donny")); + assertEquals(16, run.apply("lou")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testStackedMultiForkingStep() throws Exception + { + TestWorkflowDefinitions.registerTestWorkflowTypes(); + + ///////////////////////////////////////////// + // build a workflow with two 2-level forks // + // . 1 // + // ./ \ // + // 2 3 // + // .\ / // + // . 4 // + // ./ \ // + // 5 6 // + ///////////////////////////////////////////// + Workflow workflow = WorkflowsTestDataSource.insertWorkflowAndInitialRevision(TestWorkflowDefinitions.TEST_WORKFLOW_TYPE, null); + WorkflowsTestDataSource.insertSteps(workflow, List.of( + WorkflowsTestDataSource.newStep(1, TestWorkflowDefinitions.SPLIT_BY_LETTERS_IN_NAME, Map.of()), + WorkflowsTestDataSource.newStep(2, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 1)), // "a" + WorkflowsTestDataSource.newStep(3, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 2)), // "b" + WorkflowsTestDataSource.newStep(4, TestWorkflowDefinitions.SPLIT_BY_LETTERS_IN_NAME, Map.of()), + WorkflowsTestDataSource.newStep(5, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 4)), // "c" + WorkflowsTestDataSource.newStep(6, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 8)) // "d" + )); + WorkflowsTestDataSource.insertLinks(workflow, List.of( + WorkflowsTestDataSource.newLink(1, 2, "a"), // += 1 + WorkflowsTestDataSource.newLink(1, 3, "b"), // += 2 + WorkflowsTestDataSource.newLink(2, 4), + WorkflowsTestDataSource.newLink(3, 4), + WorkflowsTestDataSource.newLink(4, 5, "c"), // += 4 + WorkflowsTestDataSource.newLink(4, 6, "d"), // += 8 + WorkflowsTestDataSource.newLink(5, null), + WorkflowsTestDataSource.newLink(6, null) + )); + + UnsafeFunction run = (String name) -> + { + WorkflowOutput output = executeWorkflow(workflow.getId(), Map.of("seedValue", 0, "name", name)); + return ValueUtils.getValueAsInteger(output.getContext().getValues().get("sum")); + }; + + assertEquals(1, run.apply("allen")); + assertEquals(3, run.apply("barry")); + assertEquals(5, run.apply("carl")); + assertEquals(8, run.apply("donny")); + assertEquals(4, run.apply("connie")); + assertEquals(12, run.apply("doc")); + assertEquals(15, run.apply("abcd")); + assertEquals(0, run.apply("lou")); + } + + + /*************************************************************************** ** ***************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/workflows/execution/nodes/StepNodeGraphBuilderTest.java b/src/test/java/com/kingsrook/qbits/workflows/execution/nodes/StepNodeGraphBuilderTest.java new file mode 100644 index 0000000..bf50a0a --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/workflows/execution/nodes/StepNodeGraphBuilderTest.java @@ -0,0 +1,172 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qbits.workflows.execution.nodes; + + +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qbits.workflows.BaseTest; +import com.kingsrook.qbits.workflows.TestWorkflowDefinitions; +import com.kingsrook.qbits.workflows.WorkflowsTestDataSource; +import com.kingsrook.qbits.workflows.model.WorkflowLink; +import com.kingsrook.qbits.workflows.model.WorkflowStep; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + + +/******************************************************************************* + ** Unit test for StepNodeGraphBuilder + *******************************************************************************/ +class StepNodeGraphBuilderTest extends BaseTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @BeforeEach + void beforeEach() throws QException + { + TestWorkflowDefinitions.registerTestWorkflowTypes(); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testSimpleFlat() + { + List steps = new ArrayList<>(); + WorkflowStep step1 = WorkflowsTestDataSource.newStep(steps, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, null); + WorkflowStep step2 = WorkflowsTestDataSource.newStep(steps, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, null); + WorkflowStep step3 = WorkflowsTestDataSource.newStep(steps, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, null); + + List links = new ArrayList<>(); + links.add(WorkflowsTestDataSource.newLink(step1.getStepNo(), step2.getStepNo())); + links.add(WorkflowsTestDataSource.newLink(step2.getStepNo(), step3.getStepNo())); + + NodeSequence nodeSequence = new StepNodeGraphBuilder().buildNodeSequence(step1.getStepNo(), steps, links); + + assertSame(step1, nodeSequence.get(0).step()); + assertSame(step2, nodeSequence.get(1).step()); + assertSame(step3, nodeSequence.get(2).step()); + } + + + + /******************************************************************************* + * we'll evolve this graph through these 4 versions: + * + * . add1 > add1 > add1 . > (wrap it all in a container) + * . | > | > | + * .branch > branch > branch + * ./ > / \ > / \ + * add2 > add2 add3 > add2 add3 + * . \ / + * . add4 + *******************************************************************************/ + @Test + void testSimpleBranch() + { + List steps = new ArrayList<>(); + List links = new ArrayList<>(); + + WorkflowStep add1 = WorkflowsTestDataSource.newStep(steps, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, null); + WorkflowStep branch = WorkflowsTestDataSource.newStep(steps, TestWorkflowDefinitions.BOOLEAN_CONDITIONAL, null); + WorkflowStep add2 = WorkflowsTestDataSource.newStep(steps, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, null); + + links.add(WorkflowsTestDataSource.newLink(add1.getStepNo(), branch.getStepNo())); + links.add(WorkflowsTestDataSource.newLink(branch.getStepNo(), add2.getStepNo(), "true")); + + { + NodeSequence nodeSequence = StepNodeGraphBuilder.buildNodeSequence(add1.getStepNo(), steps, links); + assertEquals(2, nodeSequence.size()); + assertSame(add1, nodeSequence.get(0).step()); + assertSame(branch, nodeSequence.get(1).step()); + + StepNode branchNode = nodeSequence.get(1); + assertEquals(1, branchNode.subSequences().size()); + assertEquals(1, branchNode.subSequences().get("true").size()); + assertSame(add2, branchNode.subSequences().get("true").get(0).step()); + } + + //////////////////////////////////// + // add a step in the false branch // + //////////////////////////////////// + WorkflowStep add3 = WorkflowsTestDataSource.newStep(steps, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, null); + links.add(WorkflowsTestDataSource.newLink(branch.getStepNo(), add3.getStepNo(), "false")); + + { + NodeSequence nodeSequence = StepNodeGraphBuilder.buildNodeSequence(add1.getStepNo(), steps, links); + assertEquals(2, nodeSequence.size()); + assertSame(add1, nodeSequence.get(0).step()); + assertSame(branch, nodeSequence.get(1).step()); + + StepNode branchNode = nodeSequence.get(1); + assertEquals(2, branchNode.subSequences().size()); + assertEquals(1, branchNode.subSequences().get("true").size()); + assertSame(add2, branchNode.subSequences().get("true").get(0).step()); + assertEquals(1, branchNode.subSequences().get("false").size()); + assertSame(add3, branchNode.subSequences().get("false").get(0).step()); + } + + ///////////////////////////////////////////////// + // add a step after the branch now, and re-run // + ///////////////////////////////////////////////// + WorkflowStep add4 = WorkflowsTestDataSource.newStep(steps, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, null); + links.add(WorkflowsTestDataSource.newLink(add2.getStepNo(), add4.getStepNo())); + links.add(WorkflowsTestDataSource.newLink(add3.getStepNo(), add4.getStepNo())); + + { + NodeSequence nodeSequence = StepNodeGraphBuilder.buildNodeSequence(add1.getStepNo(), steps, links); + assertEquals(3, nodeSequence.size()); + assertSame(add1, nodeSequence.get(0).step()); + assertSame(branch, nodeSequence.get(1).step()); + assertSame(add4, nodeSequence.get(2).step()); + } + + /////////////////////////////////// + // now put it all in a container // + /////////////////////////////////// + WorkflowStep container = WorkflowsTestDataSource.newStep(steps, TestWorkflowDefinitions.CONTAINER, null); + links.add(WorkflowsTestDataSource.newLink(container.getStepNo(), add1.getStepNo(), "push")); + { + NodeSequence nodeSequence = StepNodeGraphBuilder.buildNodeSequence(container.getStepNo(), steps, links); + assertEquals(1, nodeSequence.size()); + assertSame(container, nodeSequence.get(0).step()); + + StepNode containerNode = nodeSequence.get(0); + assertEquals(1, containerNode.subSequences().size()); + NodeSequence containedSequence = containerNode.subSequences().get("push"); + + assertEquals(3, containedSequence.size()); + assertSame(add1, containedSequence.get(0).step()); + assertSame(branch, containedSequence.get(1).step()); + assertSame(add4, containedSequence.get(2).step()); + } + } + +} \ No newline at end of file diff --git a/src/test/java/com/kingsrook/qbits/workflows/processes/StoreNewWorkflowRevisionProcessTest.java b/src/test/java/com/kingsrook/qbits/workflows/processes/StoreNewWorkflowRevisionProcessTest.java index 5c3f587..38716af 100644 --- a/src/test/java/com/kingsrook/qbits/workflows/processes/StoreNewWorkflowRevisionProcessTest.java +++ b/src/test/java/com/kingsrook/qbits/workflows/processes/StoreNewWorkflowRevisionProcessTest.java @@ -83,7 +83,7 @@ class StoreNewWorkflowRevisionProcessTest extends BaseTest input.addValue("links", JsonUtils.toJson(List.of(WorkflowsTestDataSource.newLink(1, 2)))); assertThatThrownBy(() -> new StoreNewWorkflowRevisionProcess().run(input, output)) - .hasMessageContaining("2 validation errors occurred within the workflow's steps:") + .hasMessageContaining("2 validation errors occurred before the workflow could be saved:") .hasMessageContaining("Error processing Step 1: Unknown workflow step type myStepType") .hasMessageContaining("Error processing Step 2: Unknown workflow step type yourStepType"); -- 2.54.0 From 566793ff006b9c4c6bc4175fe82c89421ef06e78 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 24 Nov 2025 16:32:49 -0600 Subject: [PATCH 2/5] feat(filterInputs): Refactor getFilterFromInput, extracting getFilterFromJsonOrObject from it --- .../recordworkflows/RecordWorkflowUtils.java | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/RecordWorkflowUtils.java b/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/RecordWorkflowUtils.java index afef4ba..6a5ba76 100644 --- a/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/RecordWorkflowUtils.java +++ b/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/RecordWorkflowUtils.java @@ -83,18 +83,28 @@ public class RecordWorkflowUtils /*************************************************************************** - ** + * expects name in inputValues to be "queryFilterJson" ***************************************************************************/ public static QQueryFilter getFilterFromInput(Map inputValues) throws QException + { + Object object = inputValues.get("queryFilterJson"); + if(object == null) + { + throw (new QException("Could not find filter in input values")); + } + + return getFilterFromJsonOrObject(object); + } + + + + /*************************************************************************** + * supported input types are: QQueryFilter, String (json), Map. + ***************************************************************************/ + public static QQueryFilter getFilterFromJsonOrObject(Object object) throws QException { try { - Object object = inputValues.get("queryFilterJson"); - if(object == null) - { - throw (new QException("Could not find filter in input values")); - } - if(object instanceof QQueryFilter qQueryFilter) { return (qQueryFilter); @@ -114,7 +124,7 @@ public class RecordWorkflowUtils } catch(IOException e) { - throw new QException("Error getting filter from input values", e); + throw new QException("Error getting filter from Object", e); } } -- 2.54.0 From 1fc7091657c95372bd4e25869bae949b8b18ee47 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 24 Nov 2025 16:33:23 -0600 Subject: [PATCH 3/5] feat(apiVersions): Look for apiName and apiVersion params as top-level params (in addition to under "workflowRevision." prefix) --- .../RecordWorkflowInputRecordFilterWidget.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/RecordWorkflowInputRecordFilterWidget.java b/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/RecordWorkflowInputRecordFilterWidget.java index f35e640..f8c332e 100644 --- a/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/RecordWorkflowInputRecordFilterWidget.java +++ b/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/RecordWorkflowInputRecordFilterWidget.java @@ -98,6 +98,11 @@ public class RecordWorkflowInputRecordFilterWidget extends AbstractWidgetRendere public static void applyApiNameAndVersionFromInputToWidgetData(RenderWidgetInput input, FilterAndColumnsSetupData widgetData) { String apiName = input.getQueryParams().get("workflowRevision.apiName"); + if(!StringUtils.hasContent(apiName)) + { + apiName = input.getQueryParams().get("apiName"); + } + if(StringUtils.hasContent(apiName)) { widgetData.setApiName(apiName); @@ -113,6 +118,11 @@ public class RecordWorkflowInputRecordFilterWidget extends AbstractWidgetRendere } String apiVersion = input.getQueryParams().get("workflowRevision.apiVersion"); + if(!StringUtils.hasContent(apiVersion)) + { + apiVersion = input.getQueryParams().get("apiVersion"); + } + if(StringUtils.hasContent(apiVersion)) { widgetData.setApiVersion(apiVersion); -- 2.54.0 From 0c1a7c24e6fac391c5e465d2eae1e162f3f66f31 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 26 Nov 2025 16:24:27 -0600 Subject: [PATCH 4/5] chore: add publish_feature --- .circleci/config.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index c7aa4b2..58f531b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -24,6 +24,18 @@ workflows: only: - develop + publish_feature: + jobs: + - qqq-orb/mvn_frontend_publish: + context: [qqq-maven-registry-credentials] + branch_type: feature + filters: + branches: + only: + - feature/.* + tags: + only: [/publish.*/] + publish_release_candidate: jobs: - qqq-orb/mvn_publish: -- 2.54.0 From 93581b13a1cc93b3762992fd20c2c3c5150eee1d Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 26 Nov 2025 16:31:05 -0600 Subject: [PATCH 5/5] fix: not frontend publish, guy --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 58f531b..bb9c619 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -26,7 +26,7 @@ workflows: publish_feature: jobs: - - qqq-orb/mvn_frontend_publish: + - qqq-orb/mvn_publish: context: [qqq-maven-registry-credentials] branch_type: feature filters: -- 2.54.0