Skip to content

Commit 4b2d45d

Browse files
fix(autonomous): decouple scenario deletion from simulation lifecycle (#7295) (#7296)
1 parent 71ab66e commit 4b2d45d

4 files changed

Lines changed: 111 additions & 23 deletions

File tree

openaev-api/src/main/java/io/openaev/rest/scenario/ScenarioApi.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -323,8 +323,9 @@ public Scenario updateScenario(
323323
actionPerformed = Action.DELETE,
324324
resourceType = ResourceType.SCENARIO)
325325
public void deleteScenario(@PathVariable @NotBlank final String scenarioId) {
326-
// An autonomous scenario and its single simulation are one unit: tear the run + simulation down
327-
// first (409 if the run is still active), then delete the scenario. No-op for manual scenarios.
326+
// Tear down the autonomous run's coordination first (409 if it is still active), then delete
327+
// the scenario. Finished simulations are NOT deleted - they detach and remain as history, like
328+
// any chained simulation. No-op for manual scenarios.
328329
this.autonomousRunService.deleteForScenario(scenarioId);
329330
this.scenarioService.deleteScenario(scenarioId);
330331
}

openaev-api/src/main/java/io/openaev/service/autonomous/AutonomousRunService.java

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1298,15 +1298,16 @@ public Scenario convertToManual(String runId, ConvertToManualMode mode) {
12981298
}
12991299

13001300
/**
1301-
* Tears down the autonomous run owning {@code scenarioId} together with its single underlying
1302-
* simulation (attack-path rows included) and its timeline/directives. An autonomous scenario and
1303-
* its simulation are one unit, so deleting the scenario must delete the simulation too -
1304-
* otherwise an orphan run keeps driving a simulation whose scenario is gone.
1301+
* Tears down the autonomous run owning {@code scenarioId} - its coordination row, timeline and
1302+
* directives - and halts the orchestration, so deleting the scenario never leaves an orphan run
1303+
* driving a simulation whose scenario is gone. It does NOT delete a finished LIVE simulation:
1304+
* that is history, detached by the scenario delete like any other simulation (see {@link
1305+
* #tearDownRun}). Only a non-executing plan-mode substrate simulation is removed with the run.
13051306
*
13061307
* <p>Deliberately a best-effort no-op for manual scenarios (and when the preview feature is off),
13071308
* so the generic scenario-delete endpoint can call it unconditionally. A still-active run
1308-
* (created / running / paused / waiting-input) is refused with 409: the operator must stop it
1309-
* first, mirroring the UI's disabled Delete entry.
1309+
* (created / planning / running / paused / waiting-input) is refused with 409: the operator must
1310+
* stop it first, mirroring the UI's disabled Delete entry.
13101311
*/
13111312
@Transactional(rollbackFor = Exception.class)
13121313
public void deleteForScenario(String scenarioId) {
@@ -1321,7 +1322,10 @@ public void deleteForScenario(String scenarioId) {
13211322
// treated as terminal so a stale "still running" status can't wrongly block the delete.
13221323
run = reconcileWithSimulation(run);
13231324
AutonomousRunStatus status = run.getStatus();
1325+
// Same active set as supersedePriorRun (and the frontend's isActive): PLANNING counts - the
1326+
// orchestrator is still designing the plan, so the delete must be refused mid-design too.
13241327
if (status == AutonomousRunStatus.CREATED
1328+
|| status == AutonomousRunStatus.PLANNING
13251329
|| status == AutonomousRunStatus.RUNNING
13261330
|| status == AutonomousRunStatus.PAUSED
13271331
|| status == AutonomousRunStatus.WAITING_INPUT) {
@@ -1355,20 +1359,29 @@ public void deleteForScenarioForce(String scenarioId) {
13551359
}
13561360

13571361
/**
1358-
* Tears an autonomous run down together with its underlying simulation, decision timeline, and
1359-
* steering directives, and halts the XTM One orchestration. Shared by both scenario-delete paths
1360-
* (single and bulk) so an autonomous run is cleaned up the same way however its scenario is
1361-
* deleted - never leaving an orphaned run row or a self-resuming durable execution behind.
1362+
* Tears an autonomous run's COORDINATION down - the run row, its decision timeline and steering
1363+
* directives - and halts the XTM One orchestration. Shared by both scenario-delete paths (single
1364+
* and bulk) so a run is cleaned up the same way however its scenario is deleted, never leaving an
1365+
* orphaned run row or a self-resuming durable execution behind.
1366+
*
1367+
* <p>It does NOT delete a real simulation: a finished LIVE simulation is history and is left for
1368+
* the scenario delete to detach (scenarios_exercises SET_REFERENCE_NULL) like any other
1369+
* simulation - consistent with "a scenario can carry many simulations", and matching {@link
1370+
* #supersedePriorRun}. Only a non-executing plan-mode substrate simulation (a throwaway with no
1371+
* results) is deleted with the run.
13621372
*/
13631373
private void tearDownRun(AutonomousRun run) {
13641374
// Halt the XTM One orchestration first: once the run row is gone OpenAEV can no longer be
1365-
// driven, but a still-live durable execution would keep self-resuming and dispatching injects
1366-
// against the deleted simulation. Fired after commit (the run id is captured now) so the
1367-
// upstream cancel resolves the same execution by its stable dedup key. Purge so no orphaned
1368-
// shared state / work items linger after the scenario and its simulation are gone.
1375+
// driven, but a still-live durable execution would keep self-resuming and dispatching injects.
1376+
// Fired after commit (the run id is captured now) so the upstream cancel resolves the same
1377+
// execution by its stable dedup key. Purge so no orphaned shared state / work items linger.
13691378
String runId = run.getId();
13701379
cancelOrchestratorAfterCommit(runId, "autonomous scenario deleted", true);
1371-
if (hasText(run.getSimulationId())) {
1380+
// A plan-mode substrate simulation never executed and holds no results - delete it. A finished
1381+
// LIVE simulation is real history: keep it (the scenario delete detaches it) so deleting the
1382+
// scenario never destroys a simulation, which is the legacy 1:1 scenario<->simulation coupling
1383+
// we no longer want.
1384+
if (run.isPlanMode() && hasText(run.getSimulationId())) {
13721385
exerciseService.deleteById(run.getSimulationId());
13731386
}
13741387
directiveRepository.deleteByRunId(runId);
@@ -2879,10 +2892,11 @@ public AutonomousRun getBySimulation(String simulationId) {
28792892
}
28802893

28812894
/**
2882-
* Returns the run driving a given scenario, if any. An autonomous run owns exactly one scenario
2883-
* (and its single simulation), so this is the scenario-side twin of {@link #getBySimulation}: it
2884-
* lets the scenario detail page render the same AI-driven cockpit and steer the underlying
2885-
* simulation. 404 when the scenario is not autonomous.
2895+
* Returns the CURRENT autonomous run of a given scenario, if any. A scenario keeps at most one
2896+
* live run at a time (a rebuild or relaunch supersedes the prior run row, though its finished
2897+
* simulation stays as history - see {@link #supersedePriorRun}), so this is the scenario-side
2898+
* twin of {@link #getBySimulation}: it lets the scenario detail page render the AI-driven cockpit
2899+
* and steer the current run's simulation. 404 when the scenario has no autonomous run.
28862900
*/
28872901
@Transactional(rollbackFor = Exception.class)
28882902
public AutonomousRun getByScenario(String scenarioId) {

openaev-api/src/test/java/io/openaev/service/autonomous/AutonomousRunServiceTest.java

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@
4141
import org.junit.jupiter.api.DisplayName;
4242
import org.junit.jupiter.api.Test;
4343
import org.junit.jupiter.api.extension.ExtendWith;
44+
import org.junit.jupiter.params.ParameterizedTest;
45+
import org.junit.jupiter.params.provider.EnumSource;
4446
import org.mockito.InjectMocks;
4547
import org.mockito.Mock;
4648
import org.mockito.junit.jupiter.MockitoExtension;
@@ -708,6 +710,75 @@ void supersedeSettledRunOnManualLaunchKeepsFinishedLiveSimulation() {
708710
verify(runRepository).delete(prior);
709711
}
710712

713+
@Test
714+
@DisplayName(
715+
"deleteForScenario tears down a finished LIVE run's coordination but KEEPS its simulation as"
716+
+ " history (no legacy scenario<->simulation cascade delete)")
717+
void deleteForScenarioKeepsFinishedLiveSimulation() {
718+
when(previewFeatureService.isAutonomousAttackPathEnabled()).thenReturn(true);
719+
// Deleting a scenario must never destroy a real simulation: a completed LIVE run's simulation
720+
// is history and is left for the scenario delete to detach (scenarios_exercises
721+
// SET_REFERENCE_NULL), exactly like a manual chained simulation.
722+
AutonomousRun run = new AutonomousRun();
723+
run.setId("run-1");
724+
run.setScenarioId("scenario-1");
725+
run.setPlanMode(false);
726+
run.setStatus(AutonomousRunStatus.COMPLETED);
727+
run.setSimulationId("live-sim");
728+
when(runRepository.findByScenarioId("scenario-1")).thenReturn(Optional.of(run));
729+
730+
service.deleteForScenario("scenario-1");
731+
732+
verify(exerciseService, never()).deleteById(anyString());
733+
verify(directiveRepository).deleteByRunId("run-1");
734+
verify(eventService).deleteByRun("run-1");
735+
verify(runRepository).delete(run);
736+
verify(xtmOneClient).cancelAutonomousRun(eq("run-1"), anyString(), eq(true));
737+
}
738+
739+
@Test
740+
@DisplayName(
741+
"deleteForScenario deletes only a plan-mode substrate simulation (a throwaway with no"
742+
+ " results) with the run")
743+
void deleteForScenarioDeletesPlanSubstrate() {
744+
when(previewFeatureService.isAutonomousAttackPathEnabled()).thenReturn(true);
745+
AutonomousRun run = new AutonomousRun();
746+
run.setId("run-1");
747+
run.setScenarioId("scenario-1");
748+
run.setPlanMode(true);
749+
run.setStatus(AutonomousRunStatus.PLANNED);
750+
run.setSimulationId("plan-sim");
751+
when(runRepository.findByScenarioId("scenario-1")).thenReturn(Optional.of(run));
752+
753+
service.deleteForScenario("scenario-1");
754+
755+
verify(exerciseService).deleteById("plan-sim");
756+
verify(runRepository).delete(run);
757+
}
758+
759+
@ParameterizedTest(name = "deleteForScenario refuses (409) a still-active {0} run")
760+
@EnumSource(
761+
value = AutonomousRunStatus.class,
762+
names = {"CREATED", "PLANNING", "RUNNING", "PAUSED", "WAITING_INPUT"})
763+
@DisplayName("deleteForScenario refuses (409) while the run is still active and touches nothing")
764+
void deleteForScenarioRefusesActiveRun(AutonomousRunStatus activeStatus) {
765+
when(previewFeatureService.isAutonomousAttackPathEnabled()).thenReturn(true);
766+
AutonomousRun run = new AutonomousRun();
767+
run.setId("run-1");
768+
run.setScenarioId("scenario-1");
769+
// PLANNING is the dry-run design phase, so it only ever occurs on a plan-mode run.
770+
run.setPlanMode(activeStatus == AutonomousRunStatus.PLANNING);
771+
run.setStatus(activeStatus);
772+
when(runRepository.findByScenarioId("scenario-1")).thenReturn(Optional.of(run));
773+
774+
assertThatThrownBy(() -> service.deleteForScenario("scenario-1"))
775+
.isInstanceOfSatisfying(
776+
ResponseStatusException.class,
777+
ex -> assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.CONFLICT));
778+
verify(runRepository, never()).delete(any());
779+
verify(exerciseService, never()).deleteById(anyString());
780+
}
781+
711782
@Test
712783
@DisplayName(
713784
"supersedeSettledRunOnManualLaunch never tears down a still-active run (defensive no-op, no"

openaev-front/src/admin/components/autonomous/useAutonomousRunForSimulation.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,8 +169,10 @@ const useAutonomousRunForSimulation = (simulationId: string | undefined): Autono
169169
useAutonomousRunDetection(simulationId, fetchAutonomousRunBySimulation);
170170

171171
/**
172-
* Scenario-side twin: detects the autonomous run owning a scenario so the scenario detail page can
173-
* render the same AI cockpit and steer its single underlying simulation.
172+
* Scenario-side twin: detects the CURRENT autonomous run of a scenario so the scenario detail page
173+
* can render the same AI cockpit and steer that run's simulation. A scenario carries at most one
174+
* live run at a time (older runs are superseded, their finished simulations kept as history), so
175+
* this resolves to the current/last run - never assuming a scenario owns exactly one simulation.
174176
*
175177
* <p>Pass the scenario's own {@code scenario_autonomous} flag once the scenario is loaded: when it
176178
* is {@code false} the scenario is authoritatively manual and the lookup is skipped entirely, so a

0 commit comments

Comments
 (0)