Tell an execution's page which loops it runs

An execution's view carried each earlier round's steps but not whose
rounds they were: nodes do not carry their capabilities, so a page could
not tell a loop's guard from any other step. The view now lists the
loops the engine runs - guard, entry, the output that goes round, the
limit and the steps of each - the same after a reload as before it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-25 14:03:28 +02:00
parent 6cb62c22ea
commit a263dd5944
4 changed files with 47 additions and 0 deletions

View File

@ -340,6 +340,11 @@ public class ExecutionContext implements ExecutionListener {
loops.forEach(loop -> this.loopsByGuard.put(loop.loop().guardId(), loop));
}
/** The loops this execution may go round, in no particular order. */
public List<LoopRuntime> getLoops() {
return List.copyOf(this.loopsByGuard.values());
}
private boolean isInLoop(String stepId) {
return this.loopsByGuard.values().stream().anyMatch(loop -> loop.loop().contains(stepId));
}

View File

@ -39,6 +39,8 @@ public class ExecutionContextView {
private Map<String, ExecutionStepView> steps;
/** Loop steps as each earlier round left them, oldest first; the steps above are the current round. */
private java.util.List<ExecutionStepSnapshot> stepHistory;
/** The loops the execution may go round, so a page can tell whose rounds those are. */
private java.util.List<ExecutionLoopView> loops;
private ExecutionStatus status;
private java.util.List<String> waitingSteps;
@ -66,6 +68,7 @@ public class ExecutionContextView {
(left, right) -> right,
java.util.LinkedHashMap::new)))
.stepHistory(context.getStepHistory())
.loops(context.getLoops().stream().map(ExecutionLoopView::of).toList())
.status(context.getStatus())
.waitingSteps(context.getWaitingSteps())
.build();

View File

@ -0,0 +1,27 @@
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
// SPDX-License-Identifier: AGPL-3.0-or-later
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
package it.cnr.isti.workflow.manager.executions.api;
import java.util.List;
import it.cnr.isti.workflow.manager.executions.LoopRuntime;
/**
* A loop of a running flow, as the engine runs it.
*
* @param guardId the step that decides each time whether to go round again
* @param entryId where each new round starts
* @param backOutput the guard's output that goes round; any other one leaves the loop
* @param maxIterations how many rounds are allowed before the execution fails
* @param memberIds every step that runs again on each round
*/
public record ExecutionLoopView(String guardId, String entryId, String backOutput, int maxIterations,
List<String> memberIds) {
public static ExecutionLoopView of(LoopRuntime runtime) {
return new ExecutionLoopView(runtime.loop().guardId(), runtime.loop().entryId(), runtime.backOutput(),
runtime.loop().maxIterations(), runtime.loop().memberIds().stream().sorted().toList());
}
}

View File

@ -38,6 +38,7 @@ import it.cnr.isti.workflow.manager.blocks.types.ConditionalBlockType;
import it.cnr.isti.workflow.manager.blocks.types.EndBlockType;
import it.cnr.isti.workflow.manager.blocks.types.HumanDecisionBlockType;
import it.cnr.isti.workflow.manager.blocks.types.LLMBlockType;
import it.cnr.isti.workflow.manager.executions.api.ExecutionContextView;
import it.cnr.isti.workflow.manager.executions.persistence.ExecutionStepSnapshot;
import it.cnr.isti.workflow.manager.executions.steps.StepStatus;
import it.cnr.isti.workflow.manager.flows.model.Connection;
@ -166,6 +167,15 @@ class LoopExecutionTest {
.findFirst().orElseThrow().getValue())
.toList());
ExecutionContextView view = ExecutionContextView.fromContext(execution.getContext());
assertEquals(1, view.getLoops().size(), "the page is told which loop those rounds belong to");
assertEquals(loop.check().getId(), view.getLoops().getFirst().guardId());
assertEquals(loop.draft().getId(), view.getLoops().getFirst().entryId());
assertEquals(ConditionalBlockFactory.FALSE_OUTPUT, view.getLoops().getFirst().backOutput());
assertEquals(10, view.getLoops().getFirst().maxIterations());
assertEquals(java.util.Set.of(loop.draft().getId(), loop.check().getId()),
java.util.Set.copyOf(view.getLoops().getFirst().memberIds()));
List<ExecutionEvent> rounds = execution.getContext().getEvents().stream()
.filter(event -> event.getType() == ExecutionEventType.LOOP_ITERATION_STARTED)
.toList();
@ -255,6 +265,8 @@ class LoopExecutionTest {
ExecutionObject reloaded = executionsService.getExecution(execution.getId());
assertEquals(3, reloaded.getContext().getSteps().get(draft.getId()).getIteration());
assertEquals(4, reloaded.getContext().getStepHistory().size(), "two rounds of two steps each");
assertEquals(List.of(review.getId()), reloaded.getContext().getLoops().stream()
.map(runtime -> runtime.loop().guardId()).toList(), "the loop is known again after the reload");
executionsService.resumeExecution(execution.getId());
executionsService.setInteractionValue(execution.getId(), review.getId(),