From 058918f4d7a9322f89cac09025e0cc71dcecc48e Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Thu, 17 Sep 2026 12:54:26 +0200 Subject: [PATCH] Require the simulator's credential upfront, not on first use startSimulationExecution resolved the simulator's provider authorization lazily, inside whichever step happened to reach it first - a credential- requiring simulator would start a RUNNING execution that then failed deep inside a step, instead of being refused outright. ExecutionSimulationRequest now carries an optional credentialId (mirroring AssistantLlmSelection), and the service validates or falls back to an already-provided credential for the same provider before starting simulation at all. Fixing this surfaced a second, previously silent gap: a simulated container's child never inherited the simulator's credential, since it is never part of any execution's requiredAuthorizations and so the ordinary per-container authorization propagation loop never touched it. Every simulated container subflow would have started failing the same upfront check once it went in, so propagateSimulatorAuthorizationToChild copies the already-validated credential down to the child before it starts simulating. Co-Authored-By: Claude Sonnet 5 --- .../controllers/ExecutionsController.java | 3 +- .../ExecutionSimulationRequest.java | 8 +- .../manager/executions/ExecutionsService.java | 74 +++++++- .../controllers/ExecutionControllerTest.java | 4 +- .../manager/executions/ExecutionTest.java | 179 +++++++++++++++++- .../executions/ExecutionWithContainer.java | 2 +- .../bias/BiasExperimentsIntegrationTest.java | 4 +- 7 files changed, 261 insertions(+), 13 deletions(-) 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 6699092..c99caf9 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 @@ -272,7 +272,8 @@ public class ExecutionsController { @RequestBody @jakarta.validation.Valid ExecutionSimulationRequest request, @AuthenticationPrincipal LoginEntity userDetails) { return ExecutionView.fromExecution( - executionService.startSimulationExecution(visibleExecution(id, userDetails).getId(), request.simulator())); + executionService.startSimulationExecution(visibleExecution(id, userDetails).getId(), request.simulator(), + request.credentialId())); } @PutMapping(path = "{id}/resume") diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionSimulationRequest.java b/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionSimulationRequest.java index 20feada..3c1629c 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionSimulationRequest.java +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionSimulationRequest.java @@ -9,5 +9,11 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; public record ExecutionSimulationRequest( - @Valid @NotNull LLMDescriptor simulator) { + @Valid @NotNull LLMDescriptor simulator, + /** + * A vault secret id, required only when the simulator's provider needs one. Lets a + * credential-requiring provider be chosen as simulator on its own terms, rather than only + * working when the flow's own steps happen to already use the same provider. + */ + String credentialId) { } 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 23ebb36..1af7057 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 @@ -732,11 +732,36 @@ public class ExecutionsService { } if (executionContext.simulationEnabled() && executionContext.simulationDescriptor() != null && child.isSimulationAvailable()) { - return startSimulationExecution(childId, executionContext.simulationDescriptor()); + propagateSimulatorAuthorizationToChild(child, executionContext); + return startSimulationExecution(childId, executionContext.simulationDescriptor(), null); } return startExecution(childId); } + /** + * The simulator's credential, once resolved for the top-level execution, is not among the + * child's own {@code requiredAuthorizations} - the ordinary per-container propagation loop in + * {@link #createAndStartSubflowChild} never copies it - so without this, every simulated + * container subflow would fail the same "no saved credential" check {@link + * #startSimulationExecution} now performs, on every single container, for a credential the + * parent already proved it has. + */ + private void propagateSimulatorAuthorizationToChild(ExecutionObject child, ContainerExecutionContext executionContext) { + LLMProvider provider = AuthorizationRequirementResolver.resolveProvider(llmProviders, + executionContext.simulationDescriptor().provider()); + if (provider == null || !provider.requiresAuthorization()) { + return; + } + String key = provider.authorizationKey(); + if (child.getProvidedAuthorizations().containsKey(key)) { + return; + } + Object value = getExecution(executionContext.parentExecutionId()).getProvidedAuthorizations().get(key); + if (value != null) { + child.setAuthorization(key, value); + } + } + /** The container's own name where it can be resolved, so the message points at the diagram. */ private String containerLabel(ContainerExecutionContext executionContext) { try { @@ -1391,8 +1416,16 @@ public class ExecutionsService { return eo; } + /** + * @param credentialId a vault secret id for the simulator's own provider, or null to fall back + * to whatever the flow's own steps already provided for that exact + * provider. Validated - and, if given, stored - before simulation starts, + * so a provider that needs a credential nobody has supplied fails here, + * on this call, rather than later as a failed step once a human-interaction + * node is reached and {@link LLMCredentialResolver} finds nothing to resolve. + */ @Transactional - public ExecutionObject startSimulationExecution(String id, LLMDescriptor simulatorDescriptor) { + public ExecutionObject startSimulationExecution(String id, LLMDescriptor simulatorDescriptor, String credentialId) { ExecutionObject eo = getExecution(id); if (!eo.isSimulationAvailable()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, @@ -1402,12 +1435,49 @@ public class ExecutionsService { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Simulation requires a simulator descriptor"); } + resolveSimulatorAuthorization(eo, simulatorDescriptor, credentialId); eo.setInteractionSimulationDescriptor(simulatorDescriptor); eo.startSimulation(); touchExecution(id); return eo; } + /** + * Mirrors {@link #validateCredentialReference}, for a provider that is not among the execution's + * own {@code requiredAuthorizations} - the simulator's provider is chosen after the execution + * already exists, so it was never walked by {@link AuthorizationRequirementResolver}. + */ + private void resolveSimulatorAuthorization(ExecutionObject eo, LLMDescriptor descriptor, String credentialId) { + LLMProvider provider = AuthorizationRequirementResolver.resolveProvider(llmProviders, descriptor.provider()); + if (provider == null || !provider.requiresAuthorization()) { + return; + } + String key = provider.authorizationKey(); + String trimmedCredentialId = credentialId == null ? "" : credentialId.trim(); + if (!trimmedCredentialId.isEmpty()) { + if (eo.getOwner() == null || eo.getOwner().isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "A user-owned execution is required to select a credential for provider " + provider.getName()); + } + try { + userSecretService.requireUsableSecret(eo.getOwner(), trimmedCredentialId, provider.getName()); + } catch (ResponseStatusException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getReason(), e); + } + eo.setAuthorization(key, trimmedCredentialId); + return; + } + // No credential supplied for the simulator specifically: fall back to whatever the flow's + // own steps already provided for this exact provider - the coincidental-reuse case that has + // always worked, left unchanged and not re-validated (it resolved once already, the same + // way a container's child execution inherits its parent's without a second vault round trip). + Object existing = eo.getProvidedAuthorizations().get(key); + if (existing == null || String.valueOf(existing).isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Simulating with " + provider.getName() + " requires a saved credential"); + } + } + @Transactional public ExecutionObject resumeExecution(String id) { ExecutionObject eo = getExecution(id); 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 a5d0809..e288329 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 @@ -926,7 +926,7 @@ public class ExecutionControllerTest { chatBlock.getInputs().getFirst().getName(), "Ada Lovelace", testUser()); executionObject = executionsController.simulate(executionObject.getId(), - new ExecutionSimulationRequest(simulatorDescriptor), testUser()); + new ExecutionSimulationRequest(simulatorDescriptor, null), testUser()); org.junit.jupiter.api.Assertions.assertTrue(executionObject.isInteractionSimulationEnabled()); waitForExecutionStatus(executionObject, ExecutionStatus.SUCCESS); } @@ -964,7 +964,7 @@ public class ExecutionControllerTest { new ExecutionSimulationRequest(LLMDescriptor.builder() .provider("testProvider") .model("simulateModel") - .build()), testUser())); + .build(), null), testUser())); org.junit.jupiter.api.Assertions.assertEquals(HttpStatus.BAD_REQUEST, exception.getStatusCode()); org.junit.jupiter.api.Assertions.assertEquals(ExecutionStatus.CREATED, executionObject.getContext().getStatus()); } 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 9a33081..9afeca8 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 @@ -4,6 +4,7 @@ package it.cnr.isti.workflow.manager.executions; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; @@ -25,11 +26,16 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; +import org.springframework.http.HttpStatus; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.TestPropertySource; import org.springframework.util.ResourceUtils; import org.springframework.web.server.ResponseStatusException; +import it.cnr.isti.workflow.manager.vault.UserSecretService; +import it.cnr.isti.workflow.manager.vault.model.VaultSecretCreateRequest; +import it.cnr.isti.workflow.manager.vault.model.VaultSecretView; + import tools.jackson.core.type.TypeReference; import tools.jackson.databind.ObjectMapper; @@ -101,10 +107,53 @@ public class ExecutionTest { .provider("testProvider") .model("simulateModel") .build(); + /** A simulator provider that requires a credential, unlike testProvider - see the tests below. */ + private static final LLMDescriptor CREDENTIAL_SIMULATOR_DESCRIPTOR = LLMDescriptor.builder() + .provider("credentialTestProvider") + .model("simulateModel") + .build(); + private static final String CREDENTIAL_TEST_PROVIDER_KEY = "LLMProvider::credentialTestProvider::authorization"; @TestConfiguration static class TestConfig { + @Bean + public LLMProvider credentialTestProvider() { + return new LLMProvider() { + + @Override + public String getName() { + return "credentialTestProvider"; + } + + @Override + public List getRegisteredModels() { + return List.of(); + } + + @Override + public boolean requiresAuthorization() { + return true; + } + + @Override + public String generate(String model, String prompt) { + throw new UnsupportedOperationException("not exercised without a credential"); + } + + @Override + public String generate(String model, String prompt, String authorization) { + if (prompt.contains("###SIMULATED_CHAT_MESSAGE###")) { + return "MESSAGE: Please continue the interview"; + } + if (prompt.contains("###SIMULATED_CHAT_FINAL###")) { + return "FINAL: done using " + authorization; + } + return "used " + authorization; + } + }; + } + @Bean public LLMProvider testProvider() { return new LLMProvider() { @@ -208,6 +257,9 @@ public class ExecutionTest { @MockitoBean MCPAgentService mcpAgentService; + @Autowired + UserSecretService userSecretService; + LLMDescriptor llmBrick = LLMDescriptor.builder() .provider("testProvider") .model("testModel") @@ -427,7 +479,7 @@ public class ExecutionTest { ExecutionObject execObject = executionsService.createExecution(flow); execObject = executionsService.prepareInput(execObject.getId(), chatBlock.getId(), "cand", "John Doe"); - execObject = executionsService.startSimulationExecution(execObject.getId(), SIMULATOR_DESCRIPTOR); + execObject = executionsService.startSimulationExecution(execObject.getId(), SIMULATOR_DESCRIPTOR, null); while (execObject.getContext().getStatus() == ExecutionStatus.RUNNING) { execObject = executionsService.getExecution(execObject.getId()); } @@ -441,6 +493,125 @@ public class ExecutionTest { assertEquals(10, ((List) history).size()); } + @Test + public void startSimulationExecutionFailsImmediatelyWithNoCredentialAnywhere() { + // The gap this closes: before, this failure only ever surfaced later, inside the step that + // reached the simulator - the RUNNING execution would then quietly turn into a failed step, + // rather than the request that started simulation being refused outright. + Block chatBlock = chatInteractionBlockFactory.create(ChatInteractionBlockConfiguration.builder() + .name("Recruiter Chat") + .llmDescriptor(llmBrick) + .goalDescription("Assess ${{cand}} and reach a final decision") + .inputs(List.of(new ChatInteractionInput("cand", IOType.TEXT, false))) + .build()); + ExecutionObject execObject = executionsService.createExecution("No credential anywhere flow", + FlowData.builder().block(chatBlock).build(), "sim-owner-none"); + executionsService.prepareInput(execObject.getId(), chatBlock.getId(), "cand", "John Doe"); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, + () -> executionsService.startSimulationExecution(execObject.getId(), CREDENTIAL_SIMULATOR_DESCRIPTOR, null)); + + assertEquals(HttpStatus.BAD_REQUEST, error.getStatusCode()); + assertFalse(executionsService.getExecution(execObject.getId()).isInteractionSimulationEnabled()); + } + + @Test + public void startSimulationExecutionAcceptsAnExplicitCredentialForTheSimulator() { + VaultSecretView secret = userSecretService.create("sim-owner-explicit", new VaultSecretCreateRequest( + "sim-cred-" + java.util.UUID.randomUUID(), "credentialTestProvider", null, "secret-value", null)); + Block chatBlock = chatInteractionBlockFactory.create(ChatInteractionBlockConfiguration.builder() + .name("Recruiter Chat") + .llmDescriptor(llmBrick) + .goalDescription("Assess ${{cand}} and reach a final decision") + .inputs(List.of(new ChatInteractionInput("cand", IOType.TEXT, false))) + .build()); + ExecutionObject execObject = executionsService.createExecution("Explicit simulator credential flow", + FlowData.builder().block(chatBlock).build(), "sim-owner-explicit"); + executionsService.prepareInput(execObject.getId(), chatBlock.getId(), "cand", "John Doe"); + + execObject = executionsService.startSimulationExecution(execObject.getId(), CREDENTIAL_SIMULATOR_DESCRIPTOR, secret.id()); + execObject = waitForExecutionStatus(execObject.getId(), ExecutionStatus.SUCCESS); + + // Proves the credential's own value reached the provider, not just that no exception was + // thrown - the fake provider echoes back whatever "authorization" it was called with. + assertEquals("done using secret-value", + execObject.getContext().getResult().get(new FieldKey(chatBlock.getId(), ChatInteractionBlockFactory.RESPONSE_OUTPUT))); + } + + @Test + public void startSimulationExecutionRejectsAnUnusableExplicitCredential() { + VaultSecretView secret = userSecretService.create("someone-else", new VaultSecretCreateRequest( + "not-mine-" + java.util.UUID.randomUUID(), "credentialTestProvider", null, "secret-value", null)); + Block chatBlock = chatInteractionBlockFactory.create(ChatInteractionBlockConfiguration.builder() + .name("Recruiter Chat") + .llmDescriptor(llmBrick) + .goalDescription("Assess ${{cand}} and reach a final decision") + .inputs(List.of(new ChatInteractionInput("cand", IOType.TEXT, false))) + .build()); + ExecutionObject execObject = executionsService.createExecution("Unusable simulator credential flow", + FlowData.builder().block(chatBlock).build(), "sim-owner-wrong"); + executionsService.prepareInput(execObject.getId(), chatBlock.getId(), "cand", "John Doe"); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, + () -> executionsService.startSimulationExecution(execObject.getId(), CREDENTIAL_SIMULATOR_DESCRIPTOR, secret.id())); + + assertEquals(HttpStatus.BAD_REQUEST, error.getStatusCode()); + } + + @Test + public void startSimulationExecutionFallsBackToAnAlreadyProvidedCredentialForTheSameProvider() { + // The coincidental-reuse case that has always worked: the flow's own step already needed + // credentialTestProvider and the user already provided one for it, so choosing the same + // provider as simulator - with no separate credentialId - still resolves, unchanged. + VaultSecretView secret = userSecretService.create("sim-owner-reuse", new VaultSecretCreateRequest( + "flow-cred-" + java.util.UUID.randomUUID(), "credentialTestProvider", null, "flow-secret", null)); + Block ownBlock = llmBlockFactory.create(LLMBlockConfiguration.builder() + .name("Uses the same provider") + .llmDescriptor(LLMDescriptor.builder().provider("credentialTestProvider").model("m").build()) + .prompt("static prompt, no placeholders") + .build()); + Block innerInteraction = humanInteractiveBlockFactory.create( + HumanInteractiveBlockConfiguration.builder() + .name("Review") + .actionDescription("Review the submitted value") + .build()); + ExecutionObject execObject = executionsService.createExecution("Reuse flow", + FlowData.builder().block(ownBlock).block(innerInteraction).build(), "sim-owner-reuse"); + executionsService.prepareInput(execObject.getId(), innerInteraction.getId(), "input", "submission"); + executionsService.setAuthorizationValue(execObject.getId(), CREDENTIAL_TEST_PROVIDER_KEY, secret.id()); + + assertDoesNotThrow(() -> executionsService.startSimulationExecution(execObject.getId(), + CREDENTIAL_SIMULATOR_DESCRIPTOR, null)); + } + + @Test + public void genericContainerPropagatesTheSimulatorsCredentialToItsChild() { + // Without propagation this fails exactly like + // startSimulationExecutionFailsImmediatelyWithNoCredentialAnywhere above, except inside the + // child's own step - the container would end FAILED, not SUCCESS, since the credential the + // parent already validated was never copied down to the child's own providedAuthorizations. + VaultSecretView secret = userSecretService.create("container-sim-owner", new VaultSecretCreateRequest( + "container-sim-cred-" + java.util.UUID.randomUUID(), "credentialTestProvider", null, "container-secret", null)); + Block innerInteraction = humanInteractiveBlockFactory.create( + HumanInteractiveBlockConfiguration.builder() + .name("Inner review") + .actionDescription("Review the submitted value") + .build()); + Container container = genericContainerFactory.create(GenericContainerConfiguration.builder() + .name("Simulated container needing a credential") + .subFlow(FlowData.builder().block(innerInteraction).build()) + .build()); + ExecutionObject parent = executionsService.createExecution("Container credential propagation flow", + FlowData.builder().container(container).build(), "container-sim-owner"); + executionsService.prepareInput(parent.getId(), container.getId(), "input", "submission"); + + executionsService.startSimulationExecution(parent.getId(), CREDENTIAL_SIMULATOR_DESCRIPTOR, secret.id()); + parent = waitForExecutionStatus(parent.getId(), ExecutionStatus.SUCCESS); + + Step containerStep = parent.getContext().getSteps().get(container.getId()); + assertEquals(StepStatus.COMPLETED, containerStep.getStatus()); + } + @Test public void chatInteractionExecutionKeepsConversationHistoryAcrossMessages() { Block chatBlock = chatInteractionBlockFactory.create(ChatInteractionBlockConfiguration.builder() @@ -572,7 +743,7 @@ public class ExecutionTest { ExecutionObject execution = executionsService.createExecution("MCP params", FlowData.builder().block(chatBlock).build()); executionsService.prepareInput(execution.getId(), chatBlock.getId(), "cand", "Jordan"); - execution = executionsService.startSimulationExecution(execution.getId(), llmBrick); + execution = executionsService.startSimulationExecution(execution.getId(), llmBrick, null); for (int attempt = 0; attempt < 200 && !execution.getContext().getStatus().isFinalState(); attempt++) { try { Thread.sleep(50); @@ -718,7 +889,7 @@ public class ExecutionTest { ExecutionObject execObject = executionsService.createExecution(flow); execObject = executionsService.prepareInput(execObject.getId(), chatBlock.getId(), "cand", "John Doe"); - execObject = executionsService.startSimulationExecution(execObject.getId(), SIMULATOR_DESCRIPTOR); + execObject = executionsService.startSimulationExecution(execObject.getId(), SIMULATOR_DESCRIPTOR, null); while (execObject.getContext().getStatus() == ExecutionStatus.RUNNING) { execObject = executionsService.getExecution(execObject.getId()); } @@ -1956,7 +2127,7 @@ public class ExecutionTest { executionsService.prepareInput(parent.getId(), container.getId(), "input", "submission"); assertTrue(parent.isSimulationAvailable()); - executionsService.startSimulationExecution(parent.getId(), SIMULATOR_DESCRIPTOR); + executionsService.startSimulationExecution(parent.getId(), SIMULATOR_DESCRIPTOR, null); parent = waitForExecutionStatus(parent.getId(), ExecutionStatus.SUCCESS); Step containerStep = parent.getContext().getSteps().get(container.getId()); diff --git a/src/test/java/it/cnr/isti/workflow/manager/executions/ExecutionWithContainer.java b/src/test/java/it/cnr/isti/workflow/manager/executions/ExecutionWithContainer.java index 978dfb3..e9fc26f 100644 --- a/src/test/java/it/cnr/isti/workflow/manager/executions/ExecutionWithContainer.java +++ b/src/test/java/it/cnr/isti/workflow/manager/executions/ExecutionWithContainer.java @@ -134,7 +134,7 @@ public class ExecutionWithContainer { } assertEquals(ExecutionStatus.READY, execObject.getContext().getStatus()); - execObject = executionsService.startSimulationExecution(execObject.getId(), llmBrick); + execObject = executionsService.startSimulationExecution(execObject.getId(), llmBrick, null); assertEquals(ExecutionStatus.RUNNING, execObject.getContext().getStatus()); while (execObject.getContext().getStatus() == ExecutionStatus.RUNNING) { try { diff --git a/src/test/java/it/cnr/isti/workflow/manager/executions/bias/BiasExperimentsIntegrationTest.java b/src/test/java/it/cnr/isti/workflow/manager/executions/bias/BiasExperimentsIntegrationTest.java index e6dceb6..6614b82 100644 --- a/src/test/java/it/cnr/isti/workflow/manager/executions/bias/BiasExperimentsIntegrationTest.java +++ b/src/test/java/it/cnr/isti/workflow/manager/executions/bias/BiasExperimentsIntegrationTest.java @@ -920,7 +920,7 @@ class BiasExperimentsIntegrationTest { OWNER); executionsService.prepareInput(execution.getId(), decision.getId(), HumanDecisionBlockFactory.INPUT_NAME, "the answer under review"); - executionsService.startSimulationExecution(execution.getId(), simulator); + executionsService.startSimulationExecution(execution.getId(), simulator, null); waitUntilFinal(execution); assertEquals(ExecutionStatus.SUCCESS, execution.getContext().getStatus()); assertTrue(execution.isInteractionSimulationEnabled()); @@ -937,7 +937,7 @@ class BiasExperimentsIntegrationTest { List.of(baseline.annotationId()), false, BiasInterventionDirection.BIAS)), ExternalSideEffectPolicy.BLOCK, false)); - executionsService.startSimulationExecution(variant.getId(), simulator); + executionsService.startSimulationExecution(variant.getId(), simulator, null); waitUntilFinal(variant); assertEquals(ExecutionStatus.SUCCESS, variant.getContext().getStatus()); return variant;