Skip to content

SED-4838 Plan Editor: Normalized Subplan Referencing - #697

Draft
cmisev wants to merge 3 commits into
SED-4811-implement-a-dedicated-ui-to-execute-and-monitor-agentic-test-generationfrom
SED-4838-plan-editor-normalized-subplan-referencing
Draft

SED-4838 Plan Editor: Normalized Subplan Referencing#697
cmisev wants to merge 3 commits into
SED-4811-implement-a-dedicated-ui-to-execute-and-monitor-agentic-test-generationfrom
SED-4838-plan-editor-normalized-subplan-referencing

Conversation

@cmisev

@cmisev cmisev commented Aug 21, 2026

Copy link
Copy Markdown

No description provided.

cmisev and others added 2 commits August 21, 2026 10:15
…r-agentic-test-generation' into SED-4838-plan-editor-normalized-subplan-referencing

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for referencing plans by name rather than ID within automation packages, adding a new plan field to CallPlan and YamlCallPlan, updating the JSON schemas, and implementing a new ScheduleMapper. It also refactors the YAML fragment manager to instantiate mappers as singletons and handle references. The review feedback highlights three critical issues: a potential ConcurrentModificationException and incomplete circular dependency check in the recursive singleton construction of AutomationPackageYamlFragmentManager, a critical logic bug in PlanMapper.setReferences where the referrer's ID is incorrectly assigned to the CallPlan (which would cause infinite recursion), and multiple potential NullPointerExceptions in ScheduleMapper due to a lack of defensive null checks.

Comment on lines +134 to 160
private <T> T constructSingleton(Class<T> annotatedClass, Map<Class<?>, Object> singletons) {
return (T) constructSingleton(annotatedClass, singletons, annotatedClass);
}

// Instantiates a class found by scanning annotations; The class must have exactly one constructor
// whose arguments can be found in the injectables map.
@SuppressWarnings("unchecked")
private <T> T instantiateWithInjectables(Class<?> annotationType, Class<?> annotatedClass, Map<Class<?>, Object> injectables) {
var constructors = annotatedClass.getConstructors();
if (constructors.length != 1) {
throw new IllegalStateException("Expected exactly one constructor for @" + annotationType.getSimpleName() + "-annotated class "
+ annotatedClass.getName() + ", but found " + constructors.length);
}

var constructor = constructors[0];
try {
var parameters = Arrays.stream(constructor.getParameterTypes())
.map(injectables::get)
.toArray();

return (T) constructor.newInstance(parameters);
} catch (Exception e) {
throw new RuntimeException(e);
}
private <T, RT> T constructSingleton(Class<T> singletonClass, Map<Class<?>, Object> singletons, Class<RT> rootClass) {
return (T) singletons.computeIfAbsent(singletonClass, clazz -> {
var constructors = singletonClass.getConstructors();
if (constructors.length != 1) {
throw new IllegalStateException("Expected exactly one constructor for singleton class "
+ singletonClass.getName() + ", but found " + constructors.length);
}
var constructor = constructors[0];
try {
return constructor.newInstance(Arrays.stream(constructor.getParameterTypes()).map(dependentSignletonClass -> {
if (dependentSignletonClass == rootClass) {
throw new IllegalArgumentException("Circular singleton dependency while trying to instantiate " + rootClass.getName());
}
return constructSingleton(dependentSignletonClass, singletons, rootClass);
}).toArray());
} catch (Exception e) {
throw new RuntimeException(e);
}
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Using Map.computeIfAbsent recursively on a HashMap (via constructSingleton) will modify the map's structural state (modCount) during the computation, which throws a ConcurrentModificationException at runtime in Java 9+. Additionally, the circular dependency check only compares against the rootClass, which can lead to a StackOverflowError for indirect circular dependencies (e.g., A -> B -> C -> B).

This suggestion refactors the singleton construction to avoid recursive computeIfAbsent calls and uses a LinkedHashSet construction stack to robustly detect any circular dependencies.

    private <T> T constructSingleton(Class<T> annotatedClass, Map<Class<?>, Object> singletons) {
        return (T) constructSingleton(annotatedClass, singletons, new java.util.LinkedHashSet<>());
    }

    // Instantiates a class found by scanning annotations; The class must have exactly one constructor
    // whose arguments can be found in the injectables map.
    @SuppressWarnings("unchecked")
    private <T> T constructSingleton(Class<T> singletonClass, Map<Class<?>, Object> singletons, java.util.Set<Class<?>> constructionStack) {
        if (singletons.containsKey(singletonClass)) {
            return (T) singletons.get(singletonClass);
        }
        if (!constructionStack.add(singletonClass)) {
            throw new IllegalArgumentException("Circular singleton dependency detected: " + constructionStack + " -> " + singletonClass.getName());
        }
        try {
            var constructors = singletonClass.getConstructors();
            if (constructors.length != 1) {
                throw new IllegalStateException("Expected exactly one constructor for singleton class "
                    + singletonClass.getName() + ", but found " + constructors.length);
            }
            var constructor = constructors[0];
            Object[] parameters = Arrays.stream(constructor.getParameterTypes())
                .map(dependentSingletonClass -> constructSingleton(dependentSingletonClass, singletons, constructionStack))
                .toArray();
            T instance = (T) constructor.newInstance(parameters);
            singletons.put(singletonClass, instance);
            return instance;
        } catch (Exception e) {
            throw new RuntimeException(e);
        } finally {
            constructionStack.remove(singletonClass);
        }
    }

Comment on lines +95 to +106
private void setReferences(Map<String, Plan> nameToPlanMap, Plan plan, AbstractArtefact node) {
node.getChildren().forEach(a -> setReferences(nameToPlanMap, plan, a));

if (node instanceof CallPlan callPlan) {
Plan referencedPlan = nameToPlanMap.get(callPlan.getPlan());
planToReferencingPlansMap.computeIfAbsent(referencedPlan, p -> new HashSet<>()).add(plan);
if (plan != null) {
callPlan.setPlanId(plan.getId().toString());
callPlan.setPlan(null);
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

There is a critical correctness bug in setReferences:

  1. callPlan.setPlanId(plan.getId().toString()) sets the planId of the CallPlan to the ID of the parent/referrer plan (plan), rather than the referenced plan (referencedPlan). This will cause every CallPlan to call its own parent plan, resulting in infinite recursion during execution.
  2. If referencedPlan is null (e.g., the called plan is not found in the package), calling planToReferencingPlansMap.computeIfAbsent(referencedPlan, ...) will insert a null key into the map, and then setting the plan ID to the parent plan's ID will still occur.

We should only update the references if referencedPlan is successfully resolved, and we must set the planId to referencedPlan.getId().toString().

Suggested change
private void setReferences(Map<String, Plan> nameToPlanMap, Plan plan, AbstractArtefact node) {
node.getChildren().forEach(a -> setReferences(nameToPlanMap, plan, a));
if (node instanceof CallPlan callPlan) {
Plan referencedPlan = nameToPlanMap.get(callPlan.getPlan());
planToReferencingPlansMap.computeIfAbsent(referencedPlan, p -> new HashSet<>()).add(plan);
if (plan != null) {
callPlan.setPlanId(plan.getId().toString());
callPlan.setPlan(null);
}
}
}
private void setReferences(Map<String, Plan> nameToPlanMap, Plan plan, AbstractArtefact node) {
node.getChildren().forEach(a -> setReferences(nameToPlanMap, plan, a));
if (node instanceof CallPlan callPlan) {
Plan referencedPlan = nameToPlanMap.get(callPlan.getPlan());
if (referencedPlan != null) {
planToReferencingPlansMap.computeIfAbsent(referencedPlan, p -> new HashSet<>()).add(plan);
callPlan.setPlanId(referencedPlan.getId().toString());
callPlan.setPlan(null);
}
}
}

Comment on lines +39 to +64
@Override
public AutomationPackageSchedule toYamlObject(ExecutiontTaskParameters parameters) {
AutomationPackageSchedule yamlSchedule = new AutomationPackageSchedule(null);
yamlSchedule.setExecutionParameters(parameters.getExecutionsParameters().getCustomParameters());
yamlSchedule.setName(parameters.getAttribute(AbstractOrganizableObject.NAME));
yamlSchedule.setActive(parameters.isActive());
yamlSchedule.setPlanName(parameters.getExecutionsParameters().getPlan().getAttribute(AbstractOrganizableObject.NAME));
yamlSchedule.setCron(parameters.getCronExpression());
yamlSchedule.setCronExclusions(parameters.getCronExclusions().stream().map(CronExclusion::getCronExpression).toList());
return yamlSchedule;
}

@Override
public ExecutiontTaskParameters toBusinessObject(AutomationPackageSchedule yamlSchedule) {
ExecutiontTaskParameters parameters = new ExecutiontTaskParameters();

Map<String, String> attributes = new HashMap<>();
attributes.put(AbstractOrganizableObject.NAME, yamlSchedule.getName());
parameters.setAttributes(attributes);

parameters.setActive(yamlSchedule.getActive());
parameters.setCronExpression(yamlSchedule.getCron());
parameters.setCronExclusions(yamlSchedule.getCronExclusions().stream().map(e -> new CronExclusion(e, "")).toList());
parameters.setExecutionsParameters(new ExecutionParameters());
return parameters;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The toYamlObject and toBusinessObject methods are prone to NullPointerException under several scenarios:

  1. In toYamlObject, if parameters.getExecutionsParameters() or parameters.getExecutionsParameters().getPlan() is null, calling .getPlan() or .getAttribute(...) will throw a NullPointerException.
  2. In both toYamlObject and toBusinessObject, if cronExclusions is null, calling .stream() on it will throw a NullPointerException.

This suggestion adds defensive null checks to ensure robust serialization and deserialization of schedules.

    @Override
    public AutomationPackageSchedule toYamlObject(ExecutiontTaskParameters parameters) {
        AutomationPackageSchedule yamlSchedule = new AutomationPackageSchedule(null);
        if (parameters.getExecutionsParameters() != null) {
            yamlSchedule.setExecutionParameters(parameters.getExecutionsParameters().getCustomParameters());
            if (parameters.getExecutionsParameters().getPlan() != null) {
                yamlSchedule.setPlanName(parameters.getExecutionsParameters().getPlan().getAttribute(AbstractOrganizableObject.NAME));
            }
        }
        yamlSchedule.setName(parameters.getAttribute(AbstractOrganizableObject.NAME));
        yamlSchedule.setActive(parameters.isActive());
        yamlSchedule.setCron(parameters.getCronExpression());
        if (parameters.getCronExclusions() != null) {
            yamlSchedule.setCronExclusions(parameters.getCronExclusions().stream().map(CronExclusion::getCronExpression).toList());
        }
        return yamlSchedule;
    }

    @Override
    public ExecutiontTaskParameters toBusinessObject(AutomationPackageSchedule yamlSchedule) {
        ExecutiontTaskParameters parameters = new ExecutiontTaskParameters();

        Map<String, String> attributes = new HashMap<>();
        attributes.put(AbstractOrganizableObject.NAME, yamlSchedule.getName());
        parameters.setAttributes(attributes);

        parameters.setActive(yamlSchedule.getActive());
        parameters.setCronExpression(yamlSchedule.getCron());
        if (yamlSchedule.getCronExclusions() != null) {
            parameters.setCronExclusions(yamlSchedule.getCronExclusions().stream().map(e -> new CronExclusion(e, "")).toList());
        }
        parameters.setExecutionsParameters(new ExecutionParameters());
        return parameters;
    }

@david-stephan
david-stephan marked this pull request as ready for review August 26, 2026 07:08
@david-stephan
david-stephan marked this pull request as draft August 26, 2026 07:09

@david-stephan david-stephan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summarizing the important comment. We should not change the BO model. To resolve the main issue the FE will be adapted to always add callPlan with selection attributes ni the IDE. CallPlan by planID are only valid when invoking plans external to the AP.
We could introduce a new plan field in the YAML model as an alias/shortcut to call a plan by selection attributes by name.
Supporting the propagation of name change (i.e. renaming a Keyword, propagates to all callKeywords that resolved to it, is something generic impacting also a Step instance and should be implemented separately. This is not a priority for now.


@Override
public void initializeData(GlobalContext context) throws Exception {
LocalIDEState.get().useExistingAutomationPackageDirectory(new File("/Users/cyril/exense/step-backend/step/step-ap-ide/work").toPath());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That seems like a local dev/test changes please revert.

<artifactId>step-automation-packages-yaml</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure why we need these dependencies as compile scope now, not seeing related code changes.

@@ -1,3 +1,28 @@
package step.automation.packages.yaml.mappers;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

copyright should remain at the top of the file, that also create unrequired diff

@@ -1,3 +1,8 @@
package step.automation.packages.mappers.interfaces;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

idem with copyright

@@ -0,0 +1,34 @@
package step.automation.packages.mappers.interfaces;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

idem with copyright


@BusinessObjectToYamlMapping(sourceClass = ExecutiontTaskParameters.class)
@YamlToBusinessObjectMapping
public class ScheduleMapper implements BusinessObjectToYamlMapper<ExecutiontTaskParameters, AutomationPackageSchedule>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks more related to "SED-4840 Local Schedule Binding", I'm fine to do both in same PR is the effort doesn't increase drastically and they logically relate to one another

parameters.setActive(yamlSchedule.getActive());
parameters.setCronExpression(yamlSchedule.getCron());
parameters.setCronExclusions(yamlSchedule.getCronExclusions().stream().map(e -> new CronExclusion(e, "")).toList());
parameters.setExecutionsParameters(new ExecutionParameters());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should be yamlSchedule.getExecutionParameters

}

@Override
public ExecutiontTaskParameters toBusinessObject(AutomationPackageSchedule yamlSchedule) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel we're duplicating part of the logic used when deploying an AP implemented in AutomationPackageSchedulerHook.prepareExecutionTasksParamsStaging. We should create a following to algin all this at some point.


private String planId;

private String plan;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could be kept as an alias for selection by attributes by name only as for call keyword

"selectionAttributes": {
"$ref": "#/$defs/DynamicKeywordInputsDef"
},
"plan": {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we do change the schema to support an alias, we should decide if we want to go with a minor bump 1.2.1. Also I think there are some constant related to that version that should be udpated, I did not see the change in the PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants