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 8aae331..fc2e157 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 @@ -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 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 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 diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionObject.java b/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionObject.java index e8cb358..8f2de0a 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionObject.java +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionObject.java @@ -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 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 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) { 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 f37fea9..5d9d508 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 @@ -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 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 getExecutionGroupsByOwner(String owner) { + if (owner == null || owner.isBlank()) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Authentication required"); + } + + Map> 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 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 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 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 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 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 filterReusableExecutionVariableDescriptors( + Map 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 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; diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/api/ExecutionGroupView.java b/src/main/java/it/cnr/isti/workflow/manager/executions/api/ExecutionGroupView.java new file mode 100644 index 0000000..7e96251 --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/api/ExecutionGroupView.java @@ -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 executions; + + public static ExecutionGroupView fromExecutions(String groupId, List 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(); + } +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/api/ExecutionView.java b/src/main/java/it/cnr/isti/workflow/manager/executions/api/ExecutionView.java index 35d1e84..b380488 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/api/ExecutionView.java +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/api/ExecutionView.java @@ -21,6 +21,10 @@ public class ExecutionView { private List stepDependencies; private long creationTime; private String name; + private String runGroupId; + private String sourceFlowId; + private String rerunOfExecutionId; + private int runNumber; private List requiredAuthorizations; private Map 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()) diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/repo/ExecutionEntity.java b/src/main/java/it/cnr/isti/workflow/manager/executions/repo/ExecutionEntity.java index 8c7b1cd..8dda8a6 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/repo/ExecutionEntity.java +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/repo/ExecutionEntity.java @@ -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; 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 0feb81d..1e6c285 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 @@ -11,5 +11,7 @@ public interface ExecutionRepository extends JpaRepository findByOwner(String owner); Page findByOwner(String owner, Pageable pageable); Optional findByIdAndOwner(String id, String owner); + List findByRunGroupIdAndOwnerOrderByRunNumberAscCreationTimeAsc(String runGroupId, String owner); + List findBySourceFlowIdAndOwnerOrderByRunNumberAscCreationTimeAsc(String sourceFlowId, String owner); void deleteByOwner(String owner); } diff --git a/src/test/java/it/cnr/isti/workflow/manager/controllers/ExecutionControllerTest.java b/src/test/java/it/cnr/isti/workflow/manager/controllers/ExecutionControllerTest.java index 470980e..875de85 100644 --- a/src/test/java/it/cnr/isti/workflow/manager/controllers/ExecutionControllerTest.java +++ b/src/test/java/it/cnr/isti/workflow/manager/controllers/ExecutionControllerTest.java @@ -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 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 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 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 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 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 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 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 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 groups = executionsController.getGroups(owner); + Map 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(