From d7d74b07acb2efc66d9b9a591c7dbb4bb0b12157 Mon Sep 17 00:00:00 2001 From: Ricardo Zanini Date: Fri, 20 Mar 2026 19:16:49 -0400 Subject: [PATCH 1/3] no-issue: fix merging task items; determining names for multipe tasks calls Signed-off-by: Ricardo Zanini --- .../fluent/func/FuncDoTaskBuilder.java | 4 +- .../fluent/func/FuncForTaskBuilder.java | 2 +- .../fluent/func/FuncForkTaskBuilder.java | 2 +- .../fluent/func/FuncListenTaskBuilder.java | 2 +- .../fluent/func/FuncTaskItemListBuilder.java | 8 +- .../fluent/func/FuncWorkflowBuilder.java | 4 +- .../func/FuncTaskItemDefaultNamingTest.java | 106 ++++++++++ .../fluent/spec/BaseTaskItemListBuilder.java | 21 +- .../fluent/spec/BaseWorkflowBuilder.java | 18 +- .../fluent/spec/DoTaskBuilder.java | 4 +- .../fluent/spec/ForEachTaskBuilder.java | 21 +- .../fluent/spec/ForkTaskBuilder.java | 19 +- .../fluent/spec/ListenTaskBuilder.java | 2 +- .../spec/SubscriptionIteratorBuilder.java | 22 ++- .../fluent/spec/TaskItemListBuilder.java | 14 +- .../fluent/spec/TryTaskBuilder.java | 37 +++- .../fluent/spec/WorkflowBuilder.java | 4 +- .../spec/TaskItemDefaultNamingTest.java | 181 +++++++++++++++++- 18 files changed, 428 insertions(+), 43 deletions(-) create mode 100644 experimental/fluent/func/src/test/java/io/serverlessworkflow/fluent/func/FuncTaskItemDefaultNamingTest.java diff --git a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncDoTaskBuilder.java b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncDoTaskBuilder.java index b2bdf5fab..0e01dd60f 100644 --- a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncDoTaskBuilder.java +++ b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncDoTaskBuilder.java @@ -26,8 +26,8 @@ public class FuncDoTaskBuilder extends BaseDoTaskBuilder, FuncDoFluent { - public FuncDoTaskBuilder() { - super(new FuncTaskItemListBuilder()); + public FuncDoTaskBuilder(int listSizeOffset) { + super(new FuncTaskItemListBuilder(listSizeOffset)); } @Override diff --git a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncForTaskBuilder.java b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncForTaskBuilder.java index 4f9faf52f..bf64868d2 100644 --- a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncForTaskBuilder.java +++ b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncForTaskBuilder.java @@ -113,7 +113,7 @@ public FuncForTaskBuilder whileC(String expression) { } public FuncForTaskBuilder tasks(Consumer consumer) { - final FuncTaskItemListBuilder builder = new FuncTaskItemListBuilder(); + final FuncTaskItemListBuilder builder = new FuncTaskItemListBuilder(this.items.size()); consumer.accept(builder); this.items.addAll(builder.build()); return this; diff --git a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncForkTaskBuilder.java b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncForkTaskBuilder.java index 9b19af03d..7ea014d74 100644 --- a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncForkTaskBuilder.java +++ b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncForkTaskBuilder.java @@ -72,7 +72,7 @@ public FuncForkTaskBuilder branch(Function function) { @Override public FuncForkTaskBuilder branches(Consumer consumer) { - final FuncTaskItemListBuilder builder = new FuncTaskItemListBuilder(); + final FuncTaskItemListBuilder builder = new FuncTaskItemListBuilder(this.items.size()); consumer.accept(builder); this.items.addAll(builder.build()); return this; diff --git a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncListenTaskBuilder.java b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncListenTaskBuilder.java index a539414e2..55e82f106 100644 --- a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncListenTaskBuilder.java +++ b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncListenTaskBuilder.java @@ -31,7 +31,7 @@ public class FuncListenTaskBuilder private UntilPredicate untilPredicate; FuncListenTaskBuilder() { - super(new FuncTaskItemListBuilder()); + super(new FuncTaskItemListBuilder(0)); } public FuncListenTaskBuilder until(Predicate predicate, Class predClass) { diff --git a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncTaskItemListBuilder.java b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncTaskItemListBuilder.java index 3e87e469e..f35bf21f3 100644 --- a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncTaskItemListBuilder.java +++ b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncTaskItemListBuilder.java @@ -30,8 +30,8 @@ public class FuncTaskItemListBuilder extends BaseTaskItemListBuilder list) { @@ -44,8 +44,8 @@ protected FuncTaskItemListBuilder self() { } @Override - protected FuncTaskItemListBuilder newItemListBuilder() { - return new FuncTaskItemListBuilder(); + protected FuncTaskItemListBuilder newItemListBuilder(int listOffsetSize) { + return new FuncTaskItemListBuilder(listOffsetSize); } @Override diff --git a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncWorkflowBuilder.java b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncWorkflowBuilder.java index 261bd88b8..c29dc5ca6 100644 --- a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncWorkflowBuilder.java +++ b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncWorkflowBuilder.java @@ -41,8 +41,8 @@ public static FuncWorkflowBuilder workflow() { } @Override - protected FuncDoTaskBuilder newDo() { - return new FuncDoTaskBuilder(); + protected FuncDoTaskBuilder newDo(int listSizeOffset) { + return new FuncDoTaskBuilder(listSizeOffset); } @Override diff --git a/experimental/fluent/func/src/test/java/io/serverlessworkflow/fluent/func/FuncTaskItemDefaultNamingTest.java b/experimental/fluent/func/src/test/java/io/serverlessworkflow/fluent/func/FuncTaskItemDefaultNamingTest.java new file mode 100644 index 000000000..3a441b703 --- /dev/null +++ b/experimental/fluent/func/src/test/java/io/serverlessworkflow/fluent/func/FuncTaskItemDefaultNamingTest.java @@ -0,0 +1,106 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.fluent.func; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import io.serverlessworkflow.api.types.TaskItem; +import java.util.List; +import org.junit.jupiter.api.Test; + +public class FuncTaskItemDefaultNamingTest { + + @Test + void testFuncForEachTaskAutoNaming() { + // Testing the Func domain builder directly to ensure the functional DSL + // behaves identically to the spec DSL regarding default naming. + FuncTaskItemListBuilder funcBuilder = new FuncTaskItemListBuilder(0); + + funcBuilder.forEach( + null, + f -> + f.each("item") + .in("$.list") + // Inner tasks inside the Func ForEach loop + .tasks( + tb -> + tb.set(null, "$.a = 1") // Using the Func set(name, expr) shortcut + .http(null, hb -> hb.endpoint("http://func")))); + + List topItems = funcBuilder.build(); + assertEquals(1, topItems.size()); + assertEquals("for-0", topItems.get(0).getName(), "Top level Func ForEach should be for-0"); + + // Fetch the inner tasks of the Func 'for' loop + List nestedItems = topItems.get(0).getTask().getForTask().getDo(); + assertNotNull(nestedItems, "Nested Func forEach items must not be null"); + assertEquals(2, nestedItems.size()); + + // Verify inner builder list indexes independently starting at 0 + assertEquals("set-0", nestedItems.get(0).getName()); + assertEquals("http-1", nestedItems.get(1).getName()); + } + + @Test + void testFuncForkTaskMultipleBranchesAppends() { + FuncForkTaskBuilder forkBuilder = new FuncForkTaskBuilder(); + + // 1. Call branches() - list is initially empty, offset should be 0 + forkBuilder.branches(b -> b.set(null, "$.a = 1")); + + // 2. Call branch() - list has 1 item, so it should use index 1 + forkBuilder.branch((Object x) -> x); + + // 3. Call branches() again - list has 2 items, offset should be 2 + forkBuilder.branches(b -> b.set(null, "$.b = 2")); + + // Build and verify + List branches = forkBuilder.build().getFork().getBranches(); + + assertEquals(3, branches.size(), "All branches should be appended to the list"); + + assertEquals("set-0", branches.get(0).getName(), "First branches() call starts at 0"); + assertEquals("branch-1", branches.get(1).getName(), "branch() call picks up index 1"); + assertEquals("set-2", branches.get(2).getName(), "Second branches() call picks up index 2"); + } + + @Test + void testFuncForTaskMultipleTasksAppends() { + io.serverlessworkflow.fluent.func.FuncForTaskBuilder forBuilder = + new io.serverlessworkflow.fluent.func.FuncForTaskBuilder(); + + forBuilder.each("item").in("$.list"); + + // 1. Fluent builder - list is empty, offset is 0 + forBuilder.tasks(tb -> tb.set(null, "$.a = 1")); + + // 2. Functional LoopFunction shortcut - list has 1 item, index should be 1 + forBuilder.tasks(null, (Object ctx, Object item) -> ctx); + + // 3. Fluent builder again - list has 2 items, offset should be 2 + forBuilder.tasks(tb -> tb.http(null, hb -> hb.endpoint("http://test"))); + + // Build and verify + List forTasks = forBuilder.build().getDo(); + + assertEquals(3, forTasks.size(), "All tasks should be appended to the loop"); + + assertEquals("set-0", forTasks.get(0).getName(), "First fluent block starts at 0"); + assertEquals("for-task-1", forTasks.get(1).getName(), "Functional task picks up index 1"); + assertEquals("http-2", forTasks.get(2).getName(), "Second fluent block picks up index 2"); + } +} diff --git a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/BaseTaskItemListBuilder.java b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/BaseTaskItemListBuilder.java index 882631fdd..55ba761a8 100644 --- a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/BaseTaskItemListBuilder.java +++ b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/BaseTaskItemListBuilder.java @@ -46,18 +46,32 @@ public abstract class BaseTaskItemListBuilder list; + private final int offset; - public BaseTaskItemListBuilder() { + /** + * Constructs a new list builder with a specified offset for task indexing. * + * + *

The offset ensures deterministic and continuous auto-naming when appending tasks to an + * already existing list (e.g., calling {@code .tasks(...)} multiple times on a workflow builder). + * Without this offset, every new builder would restart its internal counter at 0, resulting in + * duplicate generated names (e.g., multiple "set-0" tasks). + * + * @param listSizeOffset the starting index for auto-generated task names (usually the current + * size of the task list, or 0 for nested scopes like loops). + */ + public BaseTaskItemListBuilder(int listSizeOffset) { this.list = new ArrayList<>(); + this.offset = listSizeOffset; } public BaseTaskItemListBuilder(final List list) { this.list = list; + this.offset = 0; } protected abstract SELF self(); - protected abstract SELF newItemListBuilder(); + protected abstract SELF newItemListBuilder(int listSizeOffset); protected final List mutableList() { return this.list; @@ -74,9 +88,8 @@ protected final String defaultNameAndRequireConfig( Objects.requireNonNull(cfg, "Configurer must not be null"); if (name == null || name.isBlank()) { - return taskType + "-" + this.list.size(); + return taskType + "-" + (this.list.size() + offset); } - return name; } diff --git a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/BaseWorkflowBuilder.java b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/BaseWorkflowBuilder.java index 8b042f485..bb16ad6e6 100644 --- a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/BaseWorkflowBuilder.java +++ b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/BaseWorkflowBuilder.java @@ -52,7 +52,19 @@ protected BaseWorkflowBuilder(final String name, final String namespace, final S this.workflow.setDocument(this.document); } - protected abstract DBuilder newDo(); + /** + * Creates a new task list builder initialized with the specified starting offset. * + * + *

This method allows the workflow builder to pass its current task count down to the list + * builder. This ensures that when new tasks are appended to the workflow via subsequent {@code + * .tasks(...)} invocations, the auto-generated task names continue sequentially (e.g., "set-2") + * rather than resetting and causing duplicates. + * + * @param listSizeOffset the current number of tasks already present in the workflow's {@code do} + * list. + * @return a new builder instance configured with the correct naming offset. + */ + protected abstract DBuilder newDo(int listSizeOffset); protected abstract SELF self(); @@ -117,7 +129,9 @@ public final SELF tasks(Consumer... tasks) { private SELF appendDo(Consumer configurer) { if (configurer == null) return self(); - final DBuilder doBuilder = newDo(); + int currentOffset = this.workflow.getDo() != null ? this.workflow.getDo().size() : 0; + + final DBuilder doBuilder = newDo(currentOffset); configurer.accept(doBuilder); final List newItems = doBuilder.build().getDo(); diff --git a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/DoTaskBuilder.java b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/DoTaskBuilder.java index 98f599a7b..70157672c 100644 --- a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/DoTaskBuilder.java +++ b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/DoTaskBuilder.java @@ -21,8 +21,8 @@ public class DoTaskBuilder extends BaseDoTaskBuilder implements DoFluent { - DoTaskBuilder() { - super(new TaskItemListBuilder()); + DoTaskBuilder(int listSizeOffset) { + super(new TaskItemListBuilder(listSizeOffset)); } @Override diff --git a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/ForEachTaskBuilder.java b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/ForEachTaskBuilder.java index f454e4111..d277876fd 100644 --- a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/ForEachTaskBuilder.java +++ b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/ForEachTaskBuilder.java @@ -17,7 +17,9 @@ import io.serverlessworkflow.api.types.ForTask; import io.serverlessworkflow.api.types.ForTaskConfiguration; +import io.serverlessworkflow.api.types.TaskItem; import io.serverlessworkflow.fluent.spec.spi.ForEachTaskFluent; +import java.util.List; import java.util.function.Consumer; public class ForEachTaskBuilder> @@ -61,9 +63,22 @@ public ForEachTaskBuilder whileC(final String expression) { } public ForEachTaskBuilder tasks(Consumer doBuilderConsumer) { - final T taskItemListBuilder = this.taskItemListBuilder.newItemListBuilder(); - doBuilderConsumer.accept(taskItemListBuilder); - this.forTask.setDo(taskItemListBuilder.build()); + List existingTasks = this.forTask.getDo(); + + int currentOffset = (existingTasks == null) ? 0 : existingTasks.size(); + + final T listBuilder = this.taskItemListBuilder.newItemListBuilder(currentOffset); + doBuilderConsumer.accept(listBuilder); + + List newTasks = listBuilder.build(); + if (existingTasks == null || existingTasks.isEmpty()) { + this.forTask.setDo(newTasks); + } else { + List merged = new java.util.ArrayList<>(existingTasks); + merged.addAll(newTasks); + this.forTask.setDo(merged); + } + return this; } diff --git a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/ForkTaskBuilder.java b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/ForkTaskBuilder.java index 56a211488..d3d363e15 100644 --- a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/ForkTaskBuilder.java +++ b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/ForkTaskBuilder.java @@ -17,7 +17,9 @@ import io.serverlessworkflow.api.types.ForkTask; import io.serverlessworkflow.api.types.ForkTaskConfiguration; +import io.serverlessworkflow.api.types.TaskItem; import io.serverlessworkflow.fluent.spec.spi.ForkTaskFluent; +import java.util.List; import java.util.function.Consumer; public class ForkTaskBuilder extends TaskBaseBuilder @@ -45,9 +47,22 @@ public ForkTaskBuilder compete(final boolean compete) { @Override public ForkTaskBuilder branches(Consumer branchesConsumer) { - final TaskItemListBuilder doTaskBuilder = new TaskItemListBuilder(); + List existingBranches = this.forkTaskConfiguration.getBranches(); + + int currentOffset = (existingBranches == null) ? 0 : existingBranches.size(); + + final TaskItemListBuilder doTaskBuilder = new TaskItemListBuilder(currentOffset); branchesConsumer.accept(doTaskBuilder); - this.forkTaskConfiguration.setBranches(doTaskBuilder.build()); + + List newBranches = doTaskBuilder.build(); + if (existingBranches == null || existingBranches.isEmpty()) { + this.forkTaskConfiguration.setBranches(newBranches); + } else { + List merged = new java.util.ArrayList<>(existingBranches); + merged.addAll(newBranches); + this.forkTaskConfiguration.setBranches(merged); + } + return this; } diff --git a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/ListenTaskBuilder.java b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/ListenTaskBuilder.java index 760bdc49d..4e56bc2fc 100644 --- a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/ListenTaskBuilder.java +++ b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/ListenTaskBuilder.java @@ -23,7 +23,7 @@ public class ListenTaskBuilder extends AbstractListenTaskBuilder { protected ListenTaskBuilder() { - super(new TaskItemListBuilder()); + super(new TaskItemListBuilder(0)); } @Override diff --git a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/SubscriptionIteratorBuilder.java b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/SubscriptionIteratorBuilder.java index fe7201a9a..b57ef3c0b 100644 --- a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/SubscriptionIteratorBuilder.java +++ b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/SubscriptionIteratorBuilder.java @@ -16,7 +16,10 @@ package io.serverlessworkflow.fluent.spec; import io.serverlessworkflow.api.types.SubscriptionIterator; +import io.serverlessworkflow.api.types.TaskItem; import io.serverlessworkflow.fluent.spec.spi.SubscriptionIteratorFluent; +import java.util.ArrayList; +import java.util.List; import java.util.function.Consumer; public class SubscriptionIteratorBuilder> @@ -44,9 +47,22 @@ public SubscriptionIteratorBuilder at(String at) { @Override public SubscriptionIteratorBuilder tasks(Consumer doBuilderConsumer) { - final T taskItemListBuilder = this.taskItemListBuilder.newItemListBuilder(); - doBuilderConsumer.accept(taskItemListBuilder); - this.subscriptionIterator.setDo(taskItemListBuilder.build()); + List existingTasks = this.subscriptionIterator.getDo(); + + int currentOffset = (existingTasks == null) ? 0 : existingTasks.size(); + + final T listBuilder = this.taskItemListBuilder.newItemListBuilder(currentOffset); + doBuilderConsumer.accept(listBuilder); + + List newTasks = listBuilder.build(); + if (existingTasks == null || existingTasks.isEmpty()) { + this.subscriptionIterator.setDo(newTasks); + } else { + List merged = new ArrayList<>(existingTasks); + merged.addAll(newTasks); + this.subscriptionIterator.setDo(merged); + } + return this; } diff --git a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/TaskItemListBuilder.java b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/TaskItemListBuilder.java index 0c292519e..d6c16fcb2 100644 --- a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/TaskItemListBuilder.java +++ b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/TaskItemListBuilder.java @@ -25,8 +25,8 @@ public class TaskItemListBuilder extends BaseTaskItemListBuilder implements DoFluent { - public TaskItemListBuilder() { - super(); + public TaskItemListBuilder(int listOffsetSize) { + super(listOffsetSize); } public TaskItemListBuilder(List list) { @@ -39,8 +39,8 @@ protected TaskItemListBuilder self() { } @Override - protected TaskItemListBuilder newItemListBuilder() { - return new TaskItemListBuilder(); + protected TaskItemListBuilder newItemListBuilder(int listOffsetSize) { + return new TaskItemListBuilder(listOffsetSize); } @Override @@ -60,8 +60,7 @@ public TaskItemListBuilder set(String name, final String expr) { public TaskItemListBuilder forEach( String name, Consumer> itemsConfigurer) { name = defaultNameAndRequireConfig(name, itemsConfigurer, TYPE_FOR); - final ForEachTaskBuilder forBuilder = - new ForEachTaskBuilder<>(newItemListBuilder()); + final ForEachTaskBuilder forBuilder = new ForEachTaskBuilder<>(this); itemsConfigurer.accept(forBuilder); return addTaskItem(new TaskItem(name, new Task().withForTask(forBuilder.build()))); } @@ -110,8 +109,7 @@ public TaskItemListBuilder emit(String name, Consumer itemsConf public TaskItemListBuilder tryCatch( String name, Consumer> itemsConfigurer) { name = defaultNameAndRequireConfig(name, itemsConfigurer, TYPE_TRY); - final TryTaskBuilder tryBuilder = - new TryTaskBuilder<>(this.newItemListBuilder()); + final TryTaskBuilder tryBuilder = new TryTaskBuilder<>(this); itemsConfigurer.accept(tryBuilder); return addTaskItem(new TaskItem(name, new Task().withTryTask(tryBuilder.build()))); } diff --git a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/TryTaskBuilder.java b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/TryTaskBuilder.java index f8ad386a1..0ade9c130 100644 --- a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/TryTaskBuilder.java +++ b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/TryTaskBuilder.java @@ -29,9 +29,12 @@ import io.serverlessworkflow.api.types.RetryLimitAttempt; import io.serverlessworkflow.api.types.RetryPolicy; import io.serverlessworkflow.api.types.RetryPolicyJitter; +import io.serverlessworkflow.api.types.TaskItem; import io.serverlessworkflow.api.types.TimeoutAfter; import io.serverlessworkflow.api.types.TryTask; import io.serverlessworkflow.api.types.TryTaskCatch; +import java.util.ArrayList; +import java.util.List; import java.util.function.Consumer; public class TryTaskBuilder> @@ -51,9 +54,22 @@ protected TryTaskBuilder self() { } public TryTaskBuilder tryHandler(Consumer consumer) { - final T taskItemListBuilder = this.doTaskBuilderFactory.newItemListBuilder(); + List existingTasks = this.tryTask.getTry(); + + int currentOffset = (existingTasks == null) ? 0 : existingTasks.size(); + + final T taskItemListBuilder = this.doTaskBuilderFactory.newItemListBuilder(currentOffset); consumer.accept(taskItemListBuilder); - this.tryTask.setTry(taskItemListBuilder.build()); + + List newTasks = taskItemListBuilder.build(); + if (existingTasks == null || existingTasks.isEmpty()) { + this.tryTask.setTry(newTasks); + } else { + List merged = new ArrayList<>(existingTasks); + merged.addAll(newTasks); + this.tryTask.setTry(merged); + } + return this; } @@ -108,9 +124,22 @@ public TryTaskCatchBuilder errorsWith(Consumer consumer) } public TryTaskCatchBuilder doTasks(Consumer consumer) { - final T taskItemListBuilder = this.doTaskBuilderFactory.newItemListBuilder(); + List existingTasks = this.tryTaskCatch.getDo(); + + int currentOffset = (existingTasks == null) ? 0 : existingTasks.size(); + + final T taskItemListBuilder = this.doTaskBuilderFactory.newItemListBuilder(currentOffset); consumer.accept(taskItemListBuilder); - this.tryTaskCatch.setDo(taskItemListBuilder.build()); + + List newTasks = taskItemListBuilder.build(); + if (existingTasks == null || existingTasks.isEmpty()) { + this.tryTaskCatch.setDo(newTasks); + } else { + List merged = new ArrayList<>(existingTasks); + merged.addAll(newTasks); + this.tryTaskCatch.setDo(merged); + } + return this; } diff --git a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/WorkflowBuilder.java b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/WorkflowBuilder.java index 374a519f3..fd74a2834 100644 --- a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/WorkflowBuilder.java +++ b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/WorkflowBuilder.java @@ -25,8 +25,8 @@ private WorkflowBuilder(final String name, final String namespace, final String } @Override - protected DoTaskBuilder newDo() { - return new DoTaskBuilder(); + protected DoTaskBuilder newDo(int listSizeOffset) { + return new DoTaskBuilder(listSizeOffset); } public static WorkflowBuilder workflow( diff --git a/fluent/spec/src/test/java/io/serverlessworkflow/fluent/spec/TaskItemDefaultNamingTest.java b/fluent/spec/src/test/java/io/serverlessworkflow/fluent/spec/TaskItemDefaultNamingTest.java index 0c0d3887a..39a42c8ef 100644 --- a/fluent/spec/src/test/java/io/serverlessworkflow/fluent/spec/TaskItemDefaultNamingTest.java +++ b/fluent/spec/src/test/java/io/serverlessworkflow/fluent/spec/TaskItemDefaultNamingTest.java @@ -22,6 +22,7 @@ import io.serverlessworkflow.api.types.ForkTaskConfiguration; import io.serverlessworkflow.api.types.TaskItem; import io.serverlessworkflow.api.types.TryTask; +import io.serverlessworkflow.api.types.TryTaskCatch; import io.serverlessworkflow.api.types.Workflow; import java.util.List; import org.junit.jupiter.api.Test; @@ -42,7 +43,7 @@ void testTopLevelDoListAutoNaming() { .http(null, http().GET().endpoint("http://example.com")) .emit("", e -> e.event(ev -> ev.type("test.event"))) .set("explicitName", s -> s.expr("$.x = 1")) // Explicit name should be kept - .fork(null, ForkTaskBuilder::build) // No-op fork to check index 4 + .fork(null, fb -> {}) // No-op fork to check index 4 ) .build(); @@ -209,4 +210,182 @@ void testDeterministicNamingAcrossInstances() { assertEquals("set-0", nestedName1, "Nested task should reset to 0-based index"); assertEquals(nestedName1, nestedName2, "Nested task names must match exactly across instances"); } + + @Test + void testMultipleTasksAppendsMaintainOffset() { + Workflow wf = + WorkflowBuilder.workflow("flowMultipleAppends") + // First invocation: list is empty, offset is 0 + .tasks( + d -> + d.set(null, s -> s.expr("$.a = 1")) + .http(null, http().GET().endpoint("http://a"))) + // Second invocation: list has 2 items, offset passed to builder should be 2 + .tasks( + d -> + d.set(null, s -> s.expr("$.b = 2")) + .emit("", e -> e.event(ev -> ev.type("test.event")))) + .build(); + + List items = wf.getDo(); + assertNotNull(items, "Do list must not be null"); + assertEquals(4, items.size(), "All tasks from multiple appends should be merged"); + + // Verify the first invocation used 0 and 1 + assertEquals("set-0", items.get(0).getName()); + assertEquals("http-1", items.get(1).getName()); + + // Verify the second invocation picked up the offset correctly (2 and 3) + assertEquals("set-2", items.get(2).getName(), "Offset should prevent resetting to set-0"); + assertEquals("emit-3", items.get(3).getName(), "Offset should continue the sequence to 3"); + } + + @Test + void testForkTaskMultipleBranchesAppends() { + Workflow wf = + WorkflowBuilder.workflow("flowForkMultipleAppends") + .tasks( + d -> + d.fork( + null, + f -> + // 1. First call: list is empty, offset is 0 + f.branches(b -> b.set(null, s -> s.expr("$.a = 1"))) + // 2. Second call: list has 1 item, offset should be 1 + .branches(b -> b.set(null, s -> s.expr("$.b = 2"))) + // 3. Third call: list has 2 items, offset should be 2 + .branches(b -> b.http(null, http().GET().endpoint("http://test"))))) + .build(); + + List topItems = wf.getDo(); + assertEquals(1, topItems.size(), "Should have exactly one top-level task"); + assertEquals("fork-0", topItems.get(0).getName(), "Top level fork should be fork-0"); + + // Extract the branches from the fork task + ForkTaskConfiguration forkConfig = topItems.get(0).getTask().getForkTask().getFork(); + List branches = forkConfig.getBranches(); + + assertNotNull(branches, "Branches list must not be null"); + assertEquals( + 3, branches.size(), "All branches from multiple calls must be appended and merged"); + + // Verify the naming offset correctly tracked the appends + assertEquals("set-0", branches.get(0).getName(), "First branches() call starts at 0"); + assertEquals("set-1", branches.get(1).getName(), "Second branches() call picks up index 1"); + assertEquals("http-2", branches.get(2).getName(), "Third branches() call picks up index 2"); + } + + @Test + void testForEachTaskMultipleTasksAppends() { + Workflow wf = + WorkflowBuilder.workflow("flowForMultipleAppends") + .tasks( + d -> + d.forEach( + null, + f -> + f.each("item") + .in("$.list") + // 1. First call: list is empty, offset is 0 + .tasks(tb -> tb.set(null, s -> s.expr("$.a = 1"))) + // 2. Second call: list has 1 item, offset should be 1 + .tasks(tb -> tb.set(null, s -> s.expr("$.b = 2"))) + // 3. Third call: list has 2 items, offset should be 2 + .tasks(tb -> tb.http(null, http().GET().endpoint("http://test"))))) + .build(); + + List topItems = wf.getDo(); + assertEquals(1, topItems.size()); + assertEquals("for-0", topItems.get(0).getName()); + + // Extract the do tasks from the forEach loop + List nestedTasks = topItems.get(0).getTask().getForTask().getDo(); + + assertNotNull(nestedTasks, "Nested tasks list must not be null"); + assertEquals( + 3, nestedTasks.size(), "All tasks from multiple calls must be appended and merged"); + + // Verify the naming offset correctly tracked the appends + assertEquals("set-0", nestedTasks.get(0).getName(), "First tasks() call starts at 0"); + assertEquals("set-1", nestedTasks.get(1).getName(), "Second tasks() call picks up index 1"); + assertEquals("http-2", nestedTasks.get(2).getName(), "Third tasks() call picks up index 2"); + } + + @Test + void testSubscriptionIteratorMultipleTasksAppends() { + // We need a root builder to satisfy the generic requirement + TaskItemListBuilder rootBuilder = new TaskItemListBuilder(0); + SubscriptionIteratorBuilder subBuilder = + new SubscriptionIteratorBuilder<>(rootBuilder); + + // 1. First call: list is empty, offset is 0 + subBuilder.tasks(tb -> tb.set(null, s -> s.expr("$.a = 1"))); + + // 2. Second call: list has 1 item, offset should be 1 + subBuilder.tasks(tb -> tb.set(null, s -> s.expr("$.b = 2"))); + + // 3. Third call: list has 2 items, offset should be 2 + subBuilder.tasks(tb -> tb.http(null, http().GET().endpoint("http://test"))); + + // Build and verify + List nestedTasks = subBuilder.build().getDo(); + + assertNotNull(nestedTasks, "Nested tasks list must not be null"); + assertEquals( + 3, nestedTasks.size(), "All tasks from multiple calls must be appended and merged"); + + // Verify the naming offset correctly tracked the appends + assertEquals("set-0", nestedTasks.get(0).getName(), "First tasks() call starts at 0"); + assertEquals("set-1", nestedTasks.get(1).getName(), "Second tasks() call picks up index 1"); + assertEquals("http-2", nestedTasks.get(2).getName(), "Third tasks() call picks up index 2"); + } + + @Test + void testTryCatchMultipleTasksAppends() { + Workflow wf = + WorkflowBuilder.workflow("flowTryCatchMultipleAppends") + .tasks( + d -> + d.tryCatch( + null, + t -> + // Multiple tryHandler calls + t.tryHandler(tb -> tb.set(null, s -> s.expr("$.a = 1"))) + .tryHandler(tb -> tb.set(null, s -> s.expr("$.b = 2"))) + .catchHandler( + c -> + c.errorsWith(eb -> eb.type("CustomError")) + // Multiple doTasks calls inside the catch + .doTasks(cb -> cb.set(null, s -> s.expr("$.c = 3"))) + .doTasks( + cb -> + cb.http( + null, + http().GET().endpoint("http://test")))))) + .build(); + + List topItems = wf.getDo(); + assertEquals(1, topItems.size()); + assertEquals("try-0", topItems.get(0).getName()); + + TryTask tryTask = topItems.get(0).getTask().getTryTask(); + + // 1. Verify the TRY block + List tryTasks = tryTask.getTry(); + assertNotNull(tryTasks, "Try tasks list must not be null"); + assertEquals(2, tryTasks.size(), "Both tryHandler calls must be appended"); + assertEquals("set-0", tryTasks.get(0).getName(), "First tryHandler starts at 0"); + assertEquals("set-1", tryTasks.get(1).getName(), "Second tryHandler picks up index 1"); + + // 2. Verify the CATCH block + TryTaskCatch catchBlock = tryTask.getCatch(); + assertNotNull(catchBlock, "Catch block must be present"); + + List catchTasks = catchBlock.getDo(); + assertNotNull(catchTasks, "Catch tasks list must not be null"); + assertEquals(2, catchTasks.size(), "Both doTasks calls inside catch must be appended"); + assertEquals("set-0", catchTasks.get(0).getName(), "First doTasks inside catch starts at 0"); + assertEquals( + "http-1", catchTasks.get(1).getName(), "Second doTasks inside catch picks up index 1"); + } } From fc3e1156a22135dca1acde2ad2afc6514ba83a35 Mon Sep 17 00:00:00 2001 From: Ricardo Zanini Date: Mon, 23 Mar 2026 11:29:45 -0400 Subject: [PATCH 2/3] Fix FuncListenTaskBuilder to remove unnecessary sublist Signed-off-by: Ricardo Zanini --- .../serverlessworkflow/fluent/func/FuncListenTaskBuilder.java | 4 ++-- .../fluent/func/FuncTaskItemListBuilder.java | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncListenTaskBuilder.java b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncListenTaskBuilder.java index 55e82f106..91f9b7dfa 100644 --- a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncListenTaskBuilder.java +++ b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncListenTaskBuilder.java @@ -30,8 +30,8 @@ public class FuncListenTaskBuilder private UntilPredicate untilPredicate; - FuncListenTaskBuilder() { - super(new FuncTaskItemListBuilder(0)); + FuncListenTaskBuilder(FuncTaskItemListBuilder factory) { + super(factory); } public FuncListenTaskBuilder until(Predicate predicate, Class predClass) { diff --git a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncTaskItemListBuilder.java b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncTaskItemListBuilder.java index f35bf21f3..da32d61dc 100644 --- a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncTaskItemListBuilder.java +++ b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncTaskItemListBuilder.java @@ -87,7 +87,7 @@ public FuncTaskItemListBuilder emit(String name, Consumer i public FuncTaskItemListBuilder listen( String name, Consumer itemsConfigurer) { name = this.defaultNameAndRequireConfig(name, itemsConfigurer, TYPE_LISTEN); - final FuncListenTaskBuilder listenTaskJavaBuilder = new FuncListenTaskBuilder(); + final FuncListenTaskBuilder listenTaskJavaBuilder = new FuncListenTaskBuilder(this); itemsConfigurer.accept(listenTaskJavaBuilder); return this.addTaskItem( new TaskItem(name, new Task().withListenTask(listenTaskJavaBuilder.build()))); From 9a77666958669f1a7a4ee9fef285200e3d157b99 Mon Sep 17 00:00:00 2001 From: Ricardo Zanini Date: Mon, 23 Mar 2026 11:40:06 -0400 Subject: [PATCH 3/3] Fix ListenTaskBuilder redundant list Signed-off-by: Ricardo Zanini --- .../fluent/spec/ListenTaskBuilder.java | 4 +- .../fluent/spec/TaskItemListBuilder.java | 2 +- .../spec/TaskItemDefaultNamingTest.java | 44 +++++++++++++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/ListenTaskBuilder.java b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/ListenTaskBuilder.java index 4e56bc2fc..a4583aab3 100644 --- a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/ListenTaskBuilder.java +++ b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/ListenTaskBuilder.java @@ -22,8 +22,8 @@ public class ListenTaskBuilder extends AbstractListenTaskBuilder { - protected ListenTaskBuilder() { - super(new TaskItemListBuilder(0)); + protected ListenTaskBuilder(TaskItemListBuilder factory) { + super(factory); } @Override diff --git a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/TaskItemListBuilder.java b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/TaskItemListBuilder.java index d6c16fcb2..6d4c08456 100644 --- a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/TaskItemListBuilder.java +++ b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/TaskItemListBuilder.java @@ -92,7 +92,7 @@ public TaskItemListBuilder fork(String name, Consumer itemsConf @Override public TaskItemListBuilder listen(String name, Consumer itemsConfigurer) { name = defaultNameAndRequireConfig(name, itemsConfigurer, TYPE_LISTEN); - final ListenTaskBuilder listenBuilder = new ListenTaskBuilder(); + final ListenTaskBuilder listenBuilder = new ListenTaskBuilder(this); itemsConfigurer.accept(listenBuilder); return addTaskItem(new TaskItem(name, new Task().withListenTask(listenBuilder.build()))); } diff --git a/fluent/spec/src/test/java/io/serverlessworkflow/fluent/spec/TaskItemDefaultNamingTest.java b/fluent/spec/src/test/java/io/serverlessworkflow/fluent/spec/TaskItemDefaultNamingTest.java index 39a42c8ef..9d130d71f 100644 --- a/fluent/spec/src/test/java/io/serverlessworkflow/fluent/spec/TaskItemDefaultNamingTest.java +++ b/fluent/spec/src/test/java/io/serverlessworkflow/fluent/spec/TaskItemDefaultNamingTest.java @@ -20,6 +20,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import io.serverlessworkflow.api.types.ForkTaskConfiguration; +import io.serverlessworkflow.api.types.SubscriptionIterator; import io.serverlessworkflow.api.types.TaskItem; import io.serverlessworkflow.api.types.TryTask; import io.serverlessworkflow.api.types.TryTaskCatch; @@ -388,4 +389,47 @@ void testTryCatchMultipleTasksAppends() { assertEquals( "http-1", catchTasks.get(1).getName(), "Second doTasks inside catch picks up index 1"); } + + @Test + void testListenTaskSubscriptionIteratorMultipleTasksAppends() { + Workflow wf = + WorkflowBuilder.workflow("flowListenMultipleAppends") + .tasks( + d -> + d.listen( + null, // Auto-names the listen task + l -> + l.forEach( + sub -> + // 1. First call: list is empty, offset is 0 + sub.tasks(tb -> tb.set(null, s -> s.expr("$.a = 1"))) + // 2. Second call: list has 1 item, offset should be 1 + .tasks(tb -> tb.set(null, s -> s.expr("$.b = 2"))) + // 3. Third call: list has 2 items, offset should be 2 + .tasks( + tb -> + tb.http( + null, http().GET().endpoint("http://test")))))) + .build(); + + List topItems = wf.getDo(); + assertEquals(1, topItems.size(), "Should have exactly one top-level task"); + assertEquals("listen-0", topItems.get(0).getName(), "Top level listen should be listen-0"); + + // Extract the SubscriptionIterator from the listen task's foreach block + SubscriptionIterator iterator = topItems.get(0).getTask().getListenTask().getForeach(); + + assertNotNull(iterator, "SubscriptionIterator (foreach) must not be null"); + + // Extract the tasks from the iterator + List nestedTasks = iterator.getDo(); + assertNotNull(nestedTasks, "Nested tasks list must not be null"); + assertEquals( + 3, nestedTasks.size(), "All tasks from multiple calls must be appended and merged"); + + // Verify the naming offset correctly tracked the appends through the entire factory chain + assertEquals("set-0", nestedTasks.get(0).getName(), "First tasks() call starts at 0"); + assertEquals("set-1", nestedTasks.get(1).getName(), "Second tasks() call picks up index 1"); + assertEquals("http-2", nestedTasks.get(2).getName(), "Third tasks() call picks up index 2"); + } }