Add execution rerun history groups

This commit is contained in:
Lucio Lelii 2026-05-27 10:37:03 +02:00
parent e12ce82406
commit 163ba9afc2
8 changed files with 438 additions and 3 deletions

View File

@ -35,6 +35,7 @@ import it.cnr.isti.workflow.manager.executions.ExecutionAuthorizationValueReques
import it.cnr.isti.workflow.manager.executions.ExecutionSimulationRequest;
import it.cnr.isti.workflow.manager.executions.ExecutionsService;
import it.cnr.isti.workflow.manager.executions.api.ExecutionContextView;
import it.cnr.isti.workflow.manager.executions.api.ExecutionGroupView;
import it.cnr.isti.workflow.manager.executions.api.ExecutionView;
import it.cnr.isti.workflow.manager.auth.repo.LoginEntity;
import it.cnr.isti.workflow.manager.flows.repo.FlowEntity;
@ -112,7 +113,7 @@ public class ExecutionsController {
FlowEntity flow = flowRepository.findById(flowId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Flow with id " + flowId + " not found"));
try{
ExecutionObject eo = executionService.createExecution(flow.getName(), flow.getFlow(),
ExecutionObject eo = executionService.createExecutionForFlow(flow.getId(), flow.getName(), flow.getFlow(),
userDetails == null ? null : userDetails.getUsername());
return ExecutionView.fromExecution(eo);
} catch (ResponseStatusException e) {
@ -150,6 +151,18 @@ public class ExecutionsController {
.map(ExecutionView::fromExecution);
}
@Operation(summary = "Retrieves execution groups",
description = "Retrieves all execution history groups for the authenticated user, including their executions.")
@GetMapping(path = "groups")
public List<ExecutionGroupView> getGroups(@AuthenticationPrincipal LoginEntity userDetails) {
if (userDetails == null) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Authentication required");
}
return executionService.getExecutionGroupsByOwner(userDetails.getUsername()).stream()
.map(group -> ExecutionGroupView.fromExecutions(group.groupId(), group.executions()))
.toList();
}
/**
@ -192,6 +205,18 @@ public class ExecutionsController {
return visibleExecution(id, userDetails).getContext().getEvents();
}
@GetMapping(path = "{id}/history")
@Operation(summary = "Retrieves execution history",
description = "Retrieves all executions belonging to the same rerun history as the selected execution.")
public List<ExecutionView> getHistory(@PathVariable String id, @AuthenticationPrincipal LoginEntity userDetails) {
if (userDetails == null) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Authentication required");
}
return executionService.getExecutionHistoryByOwner(id, userDetails.getUsername()).stream()
.map(ExecutionView::fromExecution)
.toList();
}
/**
* Deletes an execution with the specified ID.
*
@ -239,6 +264,16 @@ public class ExecutionsController {
return ExecutionView.fromExecution(executionService.cancelExecution(visibleExecution(id, userDetails).getId()));
}
@PostMapping(path = "{id}/rerun")
@Operation(summary = "Creates a rerun from a final execution",
description = "Creates a new execution with the same workflow inputs and links it to the source execution history. The source execution must be in a final state.")
public ExecutionView rerun(@PathVariable String id, @AuthenticationPrincipal LoginEntity userDetails) {
if (userDetails == null) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Authentication required");
}
return ExecutionView.fromExecution(executionService.rerunExecution(id, userDetails.getUsername()));
}
/**
* Prepares the input for an execution by associating a given input string

View File

@ -51,6 +51,14 @@ public class ExecutionObject {
String owner;
String runGroupId;
String sourceFlowId;
String rerunOfExecutionId;
int runNumber = 1;
@JsonIgnore
FlowData flow;
@ -71,9 +79,15 @@ public class ExecutionObject {
@Builder
public ExecutionObject(String executionName, FlowData flow, List<ExecutionAuthorizationRequirement> requiredAuthorizations,
String owner) {
String owner, String runGroupId, String sourceFlowId, String rerunOfExecutionId, Integer runNumber) {
this.name = executionName;
this.owner = owner;
this.sourceFlowId = sourceFlowId;
this.runGroupId = runGroupId == null || runGroupId.isBlank()
? sourceFlowId == null || sourceFlowId.isBlank() ? this.id : sourceFlowId
: runGroupId;
this.rerunOfExecutionId = rerunOfExecutionId;
this.runNumber = runNumber == null || runNumber < 1 ? 1 : runNumber;
this.flow = flow;
this.stepConnections = flow.getConnections() == null ? List.of() : List.copyOf(flow.getConnections());
this.stepDependencies = flow.getDependencies() == null ? List.of() : List.copyOf(flow.getDependencies());
@ -333,6 +347,9 @@ public class ExecutionObject {
public void restore(String executionId, long creationTime, Map<String, Object> providedAuthorizations,
ExecutionSnapshot snapshot) {
this.id = executionId;
if (this.runGroupId == null || this.runGroupId.isBlank()) {
this.runGroupId = executionId;
}
this.creationTime = creationTime;
this.providedAuthorizations.clear();
if (providedAuthorizations != null) {

View File

@ -1,5 +1,6 @@
package it.cnr.isti.workflow.manager.executions;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@ -95,6 +96,18 @@ public class ExecutionsService {
@Transactional
public ExecutionObject createExecution(String executionName, FlowData flow, String owner) {
return createExecution(executionName, flow, owner, null, null, null, 1);
}
@Transactional
public ExecutionObject createExecutionForFlow(String sourceFlowId, String executionName, FlowData flow, String owner) {
int runNumber = nextRunNumberForFlow(sourceFlowId, owner);
return createExecution(executionName, flow, owner, sourceFlowId, sourceFlowId, null, runNumber);
}
@Transactional
public ExecutionObject createExecution(String executionName, FlowData flow, String owner, String runGroupId,
String sourceFlowId, String rerunOfExecutionId, Integer runNumber) {
flowExecutionValidator.validate(flow);
List<ExecutionAuthorizationRequirement> requiredAuthorizations = resolveRequiredAuthorizations(flow);
ExecutionObject execObject = ExecutionObject.builder()
@ -102,6 +115,10 @@ public class ExecutionsService {
.flow(flow)
.requiredAuthorizations(requiredAuthorizations)
.owner(owner)
.runGroupId(runGroupId)
.sourceFlowId(sourceFlowId)
.rerunOfExecutionId(rerunOfExecutionId)
.runNumber(runNumber)
.build();
attachPersistence(execObject);
executions.put(execObject.getId(), execObject);
@ -197,7 +214,85 @@ public class ExecutionsService {
return page;
}
@Transactional
@Transactional(readOnly = true)
public List<ExecutionGroup> getExecutionGroupsByOwner(String owner) {
if (owner == null || owner.isBlank()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Authentication required");
}
Map<String, List<ExecutionObject>> groups = executionRepository.findByOwner(owner).stream()
.map(entity -> executions.computeIfAbsent(entity.getId(), ignored -> rebuildExecution(entity)))
.sorted(Comparator
.comparingInt(ExecutionObject::getRunNumber)
.thenComparingLong(ExecutionObject::getCreationTime))
.collect(Collectors.groupingBy(
this::resolveHistoryGroupId,
LinkedHashMap::new,
Collectors.toList()));
List<ExecutionGroup> executionGroups = groups.entrySet().stream()
.map(entry -> new ExecutionGroup(entry.getKey(), entry.getValue()))
.sorted(Comparator
.comparingLong((ExecutionGroup group) -> group.executions().get(group.executions().size() - 1)
.getCreationTime())
.reversed())
.toList();
executionGroups.forEach(group -> group.executions()
.forEach(execution -> touchExecution(execution.getId())));
evictIfNeeded();
return executionGroups;
}
@Transactional(readOnly = true)
public List<ExecutionObject> getExecutionHistoryByOwner(String id, String owner) {
if (owner == null || owner.isBlank()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Authentication required");
}
ExecutionEntity source = executionRepository.findByIdAndOwner(id, owner)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"Execution with id " + id + " not found"));
String sourceFlowId = resolveSourceFlowId(source);
String runGroupId = resolveRunGroupId(source);
List<ExecutionEntity> history = sourceFlowId == null
? executionRepository.findByRunGroupIdAndOwnerOrderByRunNumberAscCreationTimeAsc(runGroupId, owner)
: executionRepository.findBySourceFlowIdAndOwnerOrderByRunNumberAscCreationTimeAsc(sourceFlowId, owner);
if (history.stream().noneMatch(entity -> entity.getId().equals(source.getId()))) {
history = new java.util.ArrayList<>(history);
history.add(source);
history.sort(java.util.Comparator
.comparing((ExecutionEntity entity) -> resolveRunNumber(entity))
.thenComparingLong(ExecutionEntity::getCreationTime));
}
List<ExecutionObject> loaded = history.stream()
.map(entity -> executions.computeIfAbsent(entity.getId(), ignored -> rebuildExecution(entity)))
.toList();
loaded.forEach(execution -> touchExecution(execution.getId()));
evictIfNeeded();
return loaded;
}
public record ExecutionGroup(String groupId, List<ExecutionObject> executions) {
}
@Transactional
public ExecutionObject rerunExecution(String id, String owner) {
ExecutionObject source = getExecutionByOwner(id, owner);
if (!source.getContext().getStatus().isFinalState()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Execution with id " + id + " can be rerun only from a final state");
}
String runGroupId = resolveHistoryGroupId(source);
int nextRunNumber = nextRunNumber(source.getSourceFlowId(), runGroupId, owner, source.getRunNumber());
ExecutionObject rerun = createExecution(source.getName(), source.getFlow(), owner, runGroupId,
source.getSourceFlowId(), source.getId(), nextRunNumber);
copyReusableInputs(source, rerun);
persist(rerun);
return rerun;
}
@Transactional
public void removeExecution(String id) {
ExecutionObject execution = executions.get(id);
if (execution == null && executionRepository.existsById(id)) {
@ -517,6 +612,10 @@ public class ExecutionsService {
.owner(executionObject.getOwner())
.creationTime(executionObject.getCreationTime())
.lastUpdateTime(System.currentTimeMillis())
.runGroupId(executionObject.getRunGroupId())
.sourceFlowId(executionObject.getSourceFlowId())
.rerunOfExecutionId(executionObject.getRerunOfExecutionId())
.runNumber(executionObject.getRunNumber())
.flow(executionObject.getFlow())
.snapshot(executionObject.snapshot())
.build());
@ -530,6 +629,10 @@ public class ExecutionsService {
.flow(flow)
.requiredAuthorizations(resolveRequiredAuthorizations(flow))
.owner(entity.getOwner())
.runGroupId(resolveRunGroupId(entity))
.sourceFlowId(resolveSourceFlowId(entity))
.rerunOfExecutionId(entity.getRerunOfExecutionId())
.runNumber(resolveRunNumber(entity))
.build();
ExecutionSnapshot snapshot = entity.getSnapshot();
executionObject.restore(entity.getId(), entity.getCreationTime(),
@ -539,6 +642,78 @@ public class ExecutionsService {
return executionObject;
}
private void copyReusableInputs(ExecutionObject source, ExecutionObject rerun) {
source.getContext().getInputs().forEach((key, value) -> rerun.setInput(key.nodeId(), key.fieldId(), value));
rerun.setGlobalInputDescriptors(source.getContext().getGlobalInputDescriptors());
rerun.setExecutionVariableDescriptors(filterReusableExecutionVariableDescriptors(
source.getContext().getExecutionVariableDescriptors()));
source.getProvidedAuthorizations().forEach(rerun::setAuthorization);
if (source.getInteractionSimulationDescriptor() != null) {
rerun.setInteractionSimulationDescriptor(source.getInteractionSimulationDescriptor());
}
}
private Map<String, ExecutionVariableDescriptor> filterReusableExecutionVariableDescriptors(
Map<String, ExecutionVariableDescriptor> descriptors) {
if (descriptors == null || descriptors.isEmpty()) {
return Map.of();
}
return descriptors.entrySet().stream()
.filter(entry -> entry.getValue() != null)
.filter(entry -> entry.getValue().getKind() != ExecutionVariableKind.MCP_SESSION)
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
}
private String resolveRunGroupId(ExecutionEntity entity) {
if (entity.getRunGroupId() == null || entity.getRunGroupId().isBlank()) {
String sourceFlowId = resolveSourceFlowId(entity);
return sourceFlowId == null ? entity.getId() : sourceFlowId;
}
return entity.getRunGroupId();
}
private String resolveHistoryGroupId(ExecutionObject execution) {
if (execution.getSourceFlowId() != null && !execution.getSourceFlowId().isBlank()) {
return execution.getSourceFlowId();
}
if (execution.getRunGroupId() != null && !execution.getRunGroupId().isBlank()) {
return execution.getRunGroupId();
}
return execution.getId();
}
private int resolveRunNumber(ExecutionEntity entity) {
return entity.getRunNumber() == null || entity.getRunNumber() < 1 ? 1 : entity.getRunNumber();
}
private String resolveSourceFlowId(ExecutionEntity entity) {
if (entity.getSourceFlowId() == null || entity.getSourceFlowId().isBlank()) {
return null;
}
return entity.getSourceFlowId();
}
private int nextRunNumberForFlow(String sourceFlowId, String owner) {
if (sourceFlowId == null || sourceFlowId.isBlank() || owner == null || owner.isBlank()) {
return 1;
}
return executionRepository.findBySourceFlowIdAndOwnerOrderByRunNumberAscCreationTimeAsc(sourceFlowId, owner)
.stream()
.mapToInt(this::resolveRunNumber)
.max()
.orElse(0) + 1;
}
private int nextRunNumber(String sourceFlowId, String runGroupId, String owner, int fallbackRunNumber) {
List<ExecutionEntity> history = sourceFlowId == null || sourceFlowId.isBlank()
? executionRepository.findByRunGroupIdAndOwnerOrderByRunNumberAscCreationTimeAsc(runGroupId, owner)
: executionRepository.findBySourceFlowIdAndOwnerOrderByRunNumberAscCreationTimeAsc(sourceFlowId, owner);
return history.stream()
.mapToInt(this::resolveRunNumber)
.max()
.orElse(fallbackRunNumber) + 1;
}
private void cleanupManagedResourcesIfFinal(ExecutionObject executionObject) {
if (executionObject == null || !executionObject.getContext().getStatus().isFinalState()) {
return;

View File

@ -0,0 +1,46 @@
package it.cnr.isti.workflow.manager.executions.api;
import java.util.List;
import it.cnr.isti.workflow.manager.executions.ExecutionObject;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class ExecutionGroupView {
private String id;
private String sourceFlowId;
private String name;
private String firstExecutionId;
private String latestExecutionId;
private long creationTime;
private long lastExecutionTime;
private int executionCount;
private List<ExecutionView> executions;
public static ExecutionGroupView fromExecutions(String groupId, List<ExecutionObject> executions) {
if (executions == null || executions.isEmpty()) {
return ExecutionGroupView.builder()
.id(groupId)
.executionCount(0)
.executions(List.of())
.build();
}
ExecutionObject first = executions.get(0);
ExecutionObject latest = executions.get(executions.size() - 1);
return ExecutionGroupView.builder()
.id(groupId)
.sourceFlowId(first.getSourceFlowId())
.name(latest.getName())
.firstExecutionId(first.getId())
.latestExecutionId(latest.getId())
.creationTime(first.getCreationTime())
.lastExecutionTime(latest.getCreationTime())
.executionCount(executions.size())
.executions(executions.stream().map(ExecutionView::fromExecution).toList())
.build();
}
}

View File

@ -21,6 +21,10 @@ public class ExecutionView {
private List<Dependency> stepDependencies;
private long creationTime;
private String name;
private String runGroupId;
private String sourceFlowId;
private String rerunOfExecutionId;
private int runNumber;
private List<ExecutionAuthorizationRequirement> requiredAuthorizations;
private Map<String, Object> providedAuthorizations;
private boolean interactionSimulationEnabled;
@ -37,6 +41,10 @@ public class ExecutionView {
.stepDependencies(execution.getStepDependencies())
.creationTime(execution.getCreationTime())
.name(execution.getName())
.runGroupId(execution.getRunGroupId())
.sourceFlowId(execution.getSourceFlowId())
.rerunOfExecutionId(execution.getRerunOfExecutionId())
.runNumber(execution.getRunNumber())
.requiredAuthorizations(execution.getRequiredAuthorizations())
.providedAuthorizations(execution.getProvidedAuthorizations())
.interactionSimulationEnabled(execution.isInteractionSimulationEnabled())

View File

@ -33,6 +33,14 @@ public class ExecutionEntity {
private long lastUpdateTime;
private String runGroupId;
private String sourceFlowId;
private String rerunOfExecutionId;
private Integer runNumber;
@Column(name = "flow_data", columnDefinition = "TEXT")
@Convert(converter = FlowConverter.class)
private FlowData flow;

View File

@ -11,5 +11,7 @@ public interface ExecutionRepository extends JpaRepository<ExecutionEntity, Stri
List<ExecutionEntity> findByOwner(String owner);
Page<ExecutionEntity> findByOwner(String owner, Pageable pageable);
Optional<ExecutionEntity> findByIdAndOwner(String id, String owner);
List<ExecutionEntity> findByRunGroupIdAndOwnerOrderByRunNumberAscCreationTimeAsc(String runGroupId, String owner);
List<ExecutionEntity> findBySourceFlowIdAndOwnerOrderByRunNumberAscCreationTimeAsc(String sourceFlowId, String owner);
void deleteByOwner(String owner);
}

View File

@ -34,6 +34,7 @@ import it.cnr.isti.workflow.manager.executions.ExecutionSimulationRequest;
import it.cnr.isti.workflow.manager.executions.ExecutionStatus;
import it.cnr.isti.workflow.manager.executions.ExecutionAuthorizationValueRequest;
import it.cnr.isti.workflow.manager.executions.ExecutionsService;
import it.cnr.isti.workflow.manager.executions.api.ExecutionGroupView;
import it.cnr.isti.workflow.manager.executions.api.ExecutionView;
import it.cnr.isti.workflow.manager.flows.model.Connection;
import it.cnr.isti.workflow.manager.flows.model.Dependency;
@ -347,6 +348,149 @@ public class ExecutionControllerTest {
org.junit.jupiter.api.Assertions.assertTrue(events.stream().anyMatch(event -> event.getType() == ExecutionEventType.LLM_REQUEST));
}
@Test
public void rerunRequiresFinalStateAndCreatesHistoryEntry() {
LLMDescriptor llmDescriptor = LLMDescriptor.builder()
.provider("testProvider")
.model("testModel")
.build();
Block<LLMBlockType> block = blocksController.create(LLMBlockConfiguration.builder()
.prompt("Hello, ${{name}}!")
.name("rerun-block")
.llmDescriptor(llmDescriptor)
.build());
FlowCreateRequest request = new FlowCreateRequest(
"Rerun Flow",
"Execution rerun history",
FlowData.builder().block(block).build());
ResponseEntity<FlowView> createdFlow = flowController.createFlow(request, testUser());
ExecutionView execution = executionsController.create(createdFlow.getBody().id(), testUser());
org.junit.jupiter.api.Assertions.assertEquals(1, execution.getRunNumber());
org.junit.jupiter.api.Assertions.assertEquals(createdFlow.getBody().id(), execution.getSourceFlowId());
org.junit.jupiter.api.Assertions.assertEquals(createdFlow.getBody().id(), execution.getRunGroupId());
String executionId = execution.getId();
ResponseStatusException nonFinalRerun = org.junit.jupiter.api.Assertions.assertThrows(
ResponseStatusException.class,
() -> executionsController.rerun(executionId, testUser()));
org.junit.jupiter.api.Assertions.assertEquals(HttpStatus.BAD_REQUEST, nonFinalRerun.getStatusCode());
execution = executionsController.prepareStringInputs(execution.getId(), block.getId(), "name", "Ada", testUser());
execution = executionsController.start(execution.getId(), testUser());
waitForExecutionStatus(execution, ExecutionStatus.SUCCESS);
ExecutionView rerun = executionsController.rerun(execution.getId(), testUser());
org.junit.jupiter.api.Assertions.assertNotEquals(execution.getId(), rerun.getId());
org.junit.jupiter.api.Assertions.assertEquals(createdFlow.getBody().id(), rerun.getSourceFlowId());
org.junit.jupiter.api.Assertions.assertEquals(createdFlow.getBody().id(), rerun.getRunGroupId());
org.junit.jupiter.api.Assertions.assertEquals(execution.getId(), rerun.getRerunOfExecutionId());
org.junit.jupiter.api.Assertions.assertEquals(2, rerun.getRunNumber());
org.junit.jupiter.api.Assertions.assertEquals(ExecutionStatus.READY, rerun.getContext().getStatus());
org.junit.jupiter.api.Assertions.assertEquals("Ada",
rerun.getContext().getInputs().get(new it.cnr.isti.workflow.manager.executions.FieldKey(block.getId(), "name")));
List<ExecutionView> history = executionsController.getHistory(execution.getId(), testUser());
org.junit.jupiter.api.Assertions.assertEquals(2, history.size());
org.junit.jupiter.api.Assertions.assertEquals(execution.getId(), history.get(0).getId());
org.junit.jupiter.api.Assertions.assertEquals(rerun.getId(), history.get(1).getId());
}
@Test
public void executionsCreatedFromSameFlowShareHistory() {
Block<LLMBlockType> block = blocksController.create(LLMBlockConfiguration.builder()
.prompt("Hello, ${{name}}!")
.name("same-flow-history")
.llmDescriptor(LLMDescriptor.builder().provider("testProvider").model("testModel").build())
.build());
FlowCreateRequest request = new FlowCreateRequest(
"Same Flow History",
"Executions created from the same flow id",
FlowData.builder().block(block).build());
FlowView flow = flowController.createFlow(request, testUser()).getBody();
ExecutionView first = executionsController.create(flow.id(), testUser());
Block<LLMBlockType> updatedBlock = blocksController.create(LLMBlockConfiguration.builder()
.prompt("Updated hello, ${{name}}!")
.name("same-flow-history-updated")
.llmDescriptor(LLMDescriptor.builder().provider("testProvider").model("testModel").build())
.build());
flowController.updateFlow(flow.id(), new FlowCreateRequest(
"Same Flow History",
"Updated flow with the same id",
FlowData.builder().block(updatedBlock).build()), testUser());
ExecutionView second = executionsController.create(flow.id(), testUser());
org.junit.jupiter.api.Assertions.assertEquals(flow.id(), first.getRunGroupId());
org.junit.jupiter.api.Assertions.assertEquals(flow.id(), second.getRunGroupId());
org.junit.jupiter.api.Assertions.assertEquals(1, first.getRunNumber());
org.junit.jupiter.api.Assertions.assertEquals(2, second.getRunNumber());
List<ExecutionView> history = executionsController.getHistory(first.getId(), testUser());
org.junit.jupiter.api.Assertions.assertEquals(2, history.size());
org.junit.jupiter.api.Assertions.assertEquals(first.getId(), history.get(0).getId());
org.junit.jupiter.api.Assertions.assertEquals(second.getId(), history.get(1).getId());
}
@Test
public void getExecutionGroupsReturnsHistoriesWithExecutions() {
LoginEntity owner = new LoginEntity("execution-groups-user", "testpassword");
LLMDescriptor descriptor = LLMDescriptor.builder()
.provider("testProvider")
.model("testModel")
.build();
Block<LLMBlockType> firstFlowBlock = blocksController.create(LLMBlockConfiguration.builder()
.prompt("First flow ${{name}}")
.name("first-group-flow")
.llmDescriptor(descriptor)
.build());
FlowView firstFlow = flowController.createFlow(new FlowCreateRequest(
"First Group Flow",
"Grouped executions",
FlowData.builder().block(firstFlowBlock).build()), owner).getBody();
ExecutionView firstRun = executionsController.create(firstFlow.id(), owner);
ExecutionView secondRun = executionsController.create(firstFlow.id(), owner);
Block<LLMBlockType> secondFlowBlock = blocksController.create(LLMBlockConfiguration.builder()
.prompt("Second flow ${{name}}")
.name("second-group-flow")
.llmDescriptor(descriptor)
.build());
FlowView secondFlow = flowController.createFlow(new FlowCreateRequest(
"Second Group Flow",
"Another grouped execution",
FlowData.builder().block(secondFlowBlock).build()), owner).getBody();
ExecutionView thirdRun = executionsController.create(secondFlow.id(), owner);
List<ExecutionGroupView> groups = executionsController.getGroups(owner);
Map<String, ExecutionGroupView> groupsById = groups.stream()
.collect(java.util.stream.Collectors.toMap(ExecutionGroupView::getId, group -> group));
org.junit.jupiter.api.Assertions.assertEquals(2, groups.size());
org.junit.jupiter.api.Assertions.assertTrue(groupsById.containsKey(firstFlow.id()));
org.junit.jupiter.api.Assertions.assertTrue(groupsById.containsKey(secondFlow.id()));
ExecutionGroupView firstGroup = groupsById.get(firstFlow.id());
org.junit.jupiter.api.Assertions.assertEquals(firstFlow.id(), firstGroup.getSourceFlowId());
org.junit.jupiter.api.Assertions.assertEquals(2, firstGroup.getExecutionCount());
org.junit.jupiter.api.Assertions.assertEquals(firstRun.getId(), firstGroup.getFirstExecutionId());
org.junit.jupiter.api.Assertions.assertEquals(secondRun.getId(), firstGroup.getLatestExecutionId());
org.junit.jupiter.api.Assertions.assertEquals(List.of(firstRun.getId(), secondRun.getId()),
firstGroup.getExecutions().stream().map(ExecutionView::getId).toList());
ExecutionGroupView secondGroup = groupsById.get(secondFlow.id());
org.junit.jupiter.api.Assertions.assertEquals(1, secondGroup.getExecutionCount());
org.junit.jupiter.api.Assertions.assertEquals(List.of(thirdRun.getId()),
secondGroup.getExecutions().stream().map(ExecutionView::getId).toList());
}
@Test
public void getMissingExecutionReturnsNotFound() {
ResponseStatusException exception = org.junit.jupiter.api.Assertions.assertThrows(