From f28fe0312e7b8838df407fa30d02655d87d18dfb Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Fri, 24 Jul 2026 10:58:22 +0200 Subject: [PATCH] feat: add endpoint to list all iterations of a container step A Loop/Iterator container step only ever exposes activeInnerExecutionId (the current iteration's child), overwritten on every iteration. Past iterations' child executions are never deleted from the DB, but there was no way to enumerate them - only individually fetchable by id if a caller already had it (e.g. from the event log). Adds GET /executions/{id}/node/{stepId}/iterations, returning every child execution created for that container step (one per iteration for Loop/Iterator, one per run for GenericContainer), ordered by iteration index. Backed by a new repository query (findByParentExecutionIdAndParentStepIdOrderByParentIterationIndexAscCreationTimeAsc) and ExecutionsService.getContainerIterationsByOwner(), which validates the step exists and is actually a container before querying. Verified end to end against the running service on the "test cv ranking iterator" flow: the endpoint lists all 3 iterations in order with distinct ids and SUCCESS status, returns 400 for a non-container step and 404 for an unknown step id, and each listed iteration remains individually fetchable via the existing GET /executions/{id}. Co-Authored-By: Claude Sonnet 5 --- .../controllers/ExecutionsController.java | 15 ++++++++ .../manager/executions/ExecutionsService.java | 28 ++++++++++++++ .../executions/repo/ExecutionRepository.java | 2 + .../manager/executions/ExecutionTest.java | 37 +++++++++++++++++++ 4 files changed, 82 insertions(+) diff --git a/src/main/java/it/cnr/isti/workflow/manager/controllers/ExecutionsController.java b/src/main/java/it/cnr/isti/workflow/manager/controllers/ExecutionsController.java index 60902d8..8800284 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/controllers/ExecutionsController.java +++ b/src/main/java/it/cnr/isti/workflow/manager/controllers/ExecutionsController.java @@ -219,6 +219,21 @@ public class ExecutionsController { .toList(); } + @GetMapping(path = "{id}/node/{stepId}/iterations") + @Operation(summary = "Retrieves container iterations", + description = "Retrieves every child execution created for a container step (one per Loop/Iterator " + + "iteration, or one for a GenericContainer run), ordered by iteration index. The step itself " + + "only exposes the currently active child; this is the only way to see past iterations.") + public List getContainerIterations(@PathVariable String id, @PathVariable String stepId, + @AuthenticationPrincipal LoginEntity userDetails) { + if (userDetails == null) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Authentication required"); + } + return executionService.getContainerIterationsByOwner(id, stepId, userDetails.getUsername()).stream() + .map(ExecutionView::fromExecution) + .toList(); + } + /** * Deletes an execution with the specified ID. * diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionsService.java b/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionsService.java index 8c10362..1d76e20 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionsService.java +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionsService.java @@ -367,6 +367,34 @@ public class ExecutionsService { public record ExecutionGroup(String groupId, List executions) { } + /** + * Lists every child execution created for a container step, one per + * iteration (Loop/Iterator) or run (GenericContainer), ordered by + * iteration index. The step only ever tracks the currently active child + * via its {@code ContainerContinuationSnapshot}; this method is the only + * way to see the full history, since past iterations' children are never + * deleted but aren't otherwise enumerable. + */ + @Transactional(readOnly = true) + public List getContainerIterationsByOwner(String executionId, String stepId, String owner) { + ExecutionObject parent = getExecutionByOwner(executionId, owner); + Step step = parent.getContext().getSteps().get(stepId); + if (step == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Step with id " + stepId + " not found"); + } + if (!(step.getNode() instanceof Container)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Step with id " + stepId + " is not a container"); + } + List children = executionRepository + .findByParentExecutionIdAndParentStepIdOrderByParentIterationIndexAscCreationTimeAsc(executionId, stepId); + List loaded = children.stream() + .map(entity -> executions.computeIfAbsent(entity.getId(), ignored -> rebuildExecution(entity))) + .toList(); + loaded.forEach(execution -> touchExecution(execution.getId())); + evictIfNeeded(); + return loaded; + } + @Transactional public ExecutionObject rerunExecution(String id, String owner) { ExecutionObject source = getExecutionByOwner(id, owner); diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/repo/ExecutionRepository.java b/src/main/java/it/cnr/isti/workflow/manager/executions/repo/ExecutionRepository.java index ef847c0..5ecf4d2 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/repo/ExecutionRepository.java +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/repo/ExecutionRepository.java @@ -21,5 +21,7 @@ public interface ExecutionRepository extends JpaRepository findBySourceFlowIdAndOwnerAndExecutionKindOrderByRunNumberAscCreationTimeAsc( String sourceFlowId, String owner, ExecutionKind executionKind); List findByParentExecutionIdOrderByCreationTimeAsc(String parentExecutionId); + List findByParentExecutionIdAndParentStepIdOrderByParentIterationIndexAscCreationTimeAsc( + String parentExecutionId, String parentStepId); void deleteByOwner(String owner); } diff --git a/src/test/java/it/cnr/isti/workflow/manager/executions/ExecutionTest.java b/src/test/java/it/cnr/isti/workflow/manager/executions/ExecutionTest.java index d17f188..dd70c90 100644 --- a/src/test/java/it/cnr/isti/workflow/manager/executions/ExecutionTest.java +++ b/src/test/java/it/cnr/isti/workflow/manager/executions/ExecutionTest.java @@ -1671,6 +1671,43 @@ public class ExecutionTest { && event.getDetails().get("iterationIndex") != null)); } + @Test + public void getContainerIterationsByOwnerListsOneChildPerIterationInOrder() { + Block internalBlock = llmBlockFactory.create(LLMBlockConfiguration.builder() + .name("Internal LLM") + .llmDescriptor(llmBrick) + .prompt("Hello, ${{name}}!") + .build()); + + Container container = iteratorContainerFactory.create(IteratorContainerConfiguration.builder() + .name("Iterator") + .subFlow(FlowData.builder().block(internalBlock).build()) + .iterationInput("name") + .build()); + + FlowData flow = FlowData.builder().container(container).build(); + ExecutionObject execObject = executionsService.createExecution("Iterator flow", flow, "iterations-owner"); + + executionsService.prepareInput(execObject.getId(), container.getId(), "name", List.of("Alice", "Bob", "Carol")); + execObject = executionsService.startExecution(execObject.getId()); + execObject = awaitNonBlockingContainerCompletion(execObject); + assertEquals(ExecutionStatus.SUCCESS, execObject.getContext().getStatus()); + String parentExecutionId = execObject.getId(); + + List iterations = executionsService.getContainerIterationsByOwner( + parentExecutionId, container.getId(), "iterations-owner"); + + assertEquals(3, iterations.size()); + assertEquals(List.of(1, 2, 3), iterations.stream().map(ExecutionObject::getParentIterationIndex).toList()); + assertTrue(iterations.stream().allMatch( + iteration -> iteration.getContext().getStatus() == ExecutionStatus.SUCCESS)); + assertEquals(3, iterations.stream().map(ExecutionObject::getId).distinct().count()); + assertTrue(iterations.stream().allMatch( + iteration -> iteration.getExecutionKind() == ExecutionKind.SUBFLOW + && container.getId().equals(iteration.getParentStepId()) + && parentExecutionId.equals(iteration.getParentExecutionId()))); + } + @Test public void containerOutputRejectedByDownstreamInputFailsCleanlyInsteadOfHanging() { Block internalBlock = llmBlockFactory.create(LLMBlockConfiguration.builder()