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 <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-07-24 10:58:22 +02:00
parent c6efa32c79
commit f28fe0312e
4 changed files with 82 additions and 0 deletions

View File

@ -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<ExecutionView> 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.
*

View File

@ -367,6 +367,34 @@ public class ExecutionsService {
public record ExecutionGroup(String groupId, List<ExecutionObject> 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<ExecutionObject> 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<ExecutionEntity> children = executionRepository
.findByParentExecutionIdAndParentStepIdOrderByParentIterationIndexAscCreationTimeAsc(executionId, stepId);
List<ExecutionObject> 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);

View File

@ -21,5 +21,7 @@ public interface ExecutionRepository extends JpaRepository<ExecutionEntity, Stri
List<ExecutionEntity> findBySourceFlowIdAndOwnerAndExecutionKindOrderByRunNumberAscCreationTimeAsc(
String sourceFlowId, String owner, ExecutionKind executionKind);
List<ExecutionEntity> findByParentExecutionIdOrderByCreationTimeAsc(String parentExecutionId);
List<ExecutionEntity> findByParentExecutionIdAndParentStepIdOrderByParentIterationIndexAscCreationTimeAsc(
String parentExecutionId, String parentStepId);
void deleteByOwner(String owner);
}

View File

@ -1671,6 +1671,43 @@ public class ExecutionTest {
&& event.getDetails().get("iterationIndex") != null));
}
@Test
public void getContainerIterationsByOwnerListsOneChildPerIterationInOrder() {
Block<LLMBlockType> internalBlock = llmBlockFactory.create(LLMBlockConfiguration.builder()
.name("Internal LLM")
.llmDescriptor(llmBrick)
.prompt("Hello, ${{name}}!")
.build());
Container<IteratorContainerType> 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<ExecutionObject> 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<LLMBlockType> internalBlock = llmBlockFactory.create(LLMBlockConfiguration.builder()