From 6d1e4c79e1d555c65f4a88c1763ed7e3924b5c92 Mon Sep 17 00:00:00 2001 From: michaelj Date: Mon, 18 May 2026 19:57:21 +0100 Subject: [PATCH 1/3] feat(operators): Add script operator type This allows dynamic operations to be recorded on discoveryspaces i.e. without having to install a separate operator plugin --- orchestrator/core/discoveryspace/space.py | 95 ++++++++++++++++++++++- orchestrator/core/operation/config.py | 30 ++++++- tests/core/test_operation.py | 44 +++++++++++ tests/core/test_space.py | 58 ++++++++++++++ 4 files changed, 222 insertions(+), 5 deletions(-) diff --git a/orchestrator/core/discoveryspace/space.py b/orchestrator/core/discoveryspace/space.py index 1392147fb..6db4e47ac 100644 --- a/orchestrator/core/discoveryspace/space.py +++ b/orchestrator/core/discoveryspace/space.py @@ -1,10 +1,11 @@ # Copyright IBM Corporation 2025, 2026 # SPDX-License-Identifier: MIT +import contextlib import logging import os import typing -from collections.abc import Callable +from collections.abc import Callable, Iterator from functools import wraps from typing import Any @@ -885,6 +886,98 @@ def updateOperation( self.log.info(f"Updating run {operationResource.identifier}") return self._metadataStore.updateResource(operationResource) + @contextlib.contextmanager + def operation_context( + self, + name: str, + description: str | None = None, + metadata: dict | None = None, + ) -> Iterator[str]: + """Context manager that registers a script operation and manages its lifecycle. + + Creates an OperationResource linked to this space, appends STARTED before + yielding the operation_id, and writes FINISHED/SUCCESS or FINISHED/FAIL on exit. + The operation_id should be passed as ``requesterid`` to Actuators execute + or submit methods + + Args: + name: Human-readable script name stored in the operation configuration. + description: Optional description for the operation metadata. + metadata: Optional extra metadata fields merged into ConfigurationMetadata. + + Yields: + The operation resource identifier. + + Raises: + RuntimeError: If the discovery space has no metadata store. + """ + if self._metadataStore is None: + raise RuntimeError( + "DiscoverySpace.operation_context requires a metadata store; " + "load the space from stored configuration first." + ) + + from orchestrator.core.metadata import ConfigurationMetadata + from orchestrator.core.operation.config import ( + DiscoveryOperationConfiguration, + DiscoveryOperationEnum, + DiscoveryOperationResourceConfiguration, + ScriptOperatorConf, + ) + from orchestrator.core.operation.resource import ( + OperationExitStateEnum, + OperationResource, + OperationResourceEventEnum, + OperationResourceStatus, + ) + + script_module = ScriptOperatorConf(name=name) + config_metadata = ConfigurationMetadata(name=name, description=description) + if metadata: + for key, value in metadata.items(): + setattr(config_metadata, key, value) + + operation_payload = DiscoveryOperationResourceConfiguration( + operation=DiscoveryOperationConfiguration( + module=script_module, + parameters={}, + ), + metadata=config_metadata, + spaces=[self.uri], + ) + + operation = OperationResource( + operationType=DiscoveryOperationEnum.SCRIPT, + operatorIdentifier=script_module.operatorIdentifier, + config=operation_payload, + ) + + self.addOperation(operation) + self._verified_operation_ids.add(operation.identifier) + + try: + operation.status.append( + OperationResourceStatus(event=OperationResourceEventEnum.STARTED) + ) + self.updateOperation(operation) + yield operation.identifier + operation.status.append( + OperationResourceStatus( + event=OperationResourceEventEnum.FINISHED, + exit_state=OperationExitStateEnum.SUCCESS, + ) + ) + except Exception: + operation.status.append( + OperationResourceStatus( + event=OperationResourceEventEnum.FINISHED, + exit_state=OperationExitStateEnum.FAIL, + ) + ) + raise + finally: + self.updateOperation(operation) + @_perform_preflight_checks_for_sample_store_methods def complete_measurement_request_with_results_timeseries( self, diff --git a/orchestrator/core/operation/config.py b/orchestrator/core/operation/config.py index 72ee90901..b11d424a5 100644 --- a/orchestrator/core/operation/config.py +++ b/orchestrator/core/operation/config.py @@ -38,6 +38,7 @@ class DiscoveryOperationEnum(enum.Enum): LEARN = "learn" QUERY = "query" EXPORT = "export" + SCRIPT = "script" def get_actuator_configurations( @@ -351,6 +352,24 @@ def operatorIdentifier(self) -> str: return operator.operatorIdentifier if operator else f"{self.operatorName}-None" +class ScriptOperatorConf(pydantic.BaseModel): + """Identifies an inline script or custom operator not registered in any collection.""" + + model_config = ConfigDict(extra="forbid") + name: Annotated[str, pydantic.Field(description="Human-readable script name")] + version: Annotated[str, pydantic.Field()] = "0.1.0" + + @property + def operationType(self) -> DiscoveryOperationEnum: + """Return the script operation type.""" + return DiscoveryOperationEnum.SCRIPT + + @property + def operatorIdentifier(self) -> str: + """Return the canonical script operator identifier.""" + return f"script-{self.name}-{self.version}" + + # --------------------------------------------------------------------------- # Backwards-compatibility alias — use OperatorReference in new code # --------------------------------------------------------------------------- @@ -396,7 +415,7 @@ class DiscoveryOperationConfiguration(pydantic.BaseModel): model_config = ConfigDict(extra="forbid") module: Annotated[ - OperatorModuleConf | OperatorReference, + OperatorModuleConf | OperatorReference | ScriptOperatorConf, pydantic.Field( description="The module or function providing the discovery operation" ), @@ -412,8 +431,9 @@ class DiscoveryOperationConfiguration(pydantic.BaseModel): @pydantic.field_validator("module", mode="after") @classmethod def ensure_module_is_installed( - cls, module: OperatorModuleConf | OperatorReference - ) -> OperatorModuleConf | OperatorReference: + cls, + module: OperatorModuleConf | OperatorReference | ScriptOperatorConf, + ) -> OperatorModuleConf | OperatorReference | ScriptOperatorConf: """Validates that the operator module is installed and accessible. Args: @@ -425,7 +445,7 @@ def ensure_module_is_installed( Raises: ValueError: If the operator module is not installed or cannot be imported. """ - if isinstance(module, OperatorReference): + if isinstance(module, OperatorReference | ScriptOperatorConf): return module import importlib @@ -462,6 +482,8 @@ def validate_and_downcast_parameters(self) -> Self: self.parameters = operator_metadata.configuration_model.model_validate( self.parameters ) + elif isinstance(self.module, ScriptOperatorConf): + self.parameters = {} else: from orchestrator.modules.operators.collections import ( operationCollectionMap, diff --git a/tests/core/test_operation.py b/tests/core/test_operation.py index 52cc995ba..5fa69ea75 100644 --- a/tests/core/test_operation.py +++ b/tests/core/test_operation.py @@ -11,6 +11,7 @@ DiscoveryOperationConfiguration, DiscoveryOperationEnum, DiscoveryOperationResourceConfiguration, + ScriptOperatorConf, ) from orchestrator.core.operation.resource import ( OperationExitStateEnum, @@ -186,3 +187,46 @@ def test_add_operation_result( ) -> None: pass + + +def test_script_operator_conf_round_trip() -> None: + """ScriptOperatorConf serialises and validates through operation configuration.""" + script_module = ScriptOperatorConf(name="grid-sweep", version="1.0.0") + assert script_module.operationType == DiscoveryOperationEnum.SCRIPT + assert script_module.operatorIdentifier == "script-grid-sweep-1.0.0" + + operation_configuration = DiscoveryOperationConfiguration( + module=script_module, + parameters={"ignored": "value"}, + ) + assert operation_configuration.parameters == {} + + resource_configuration = DiscoveryOperationResourceConfiguration( + operation=operation_configuration, + spaces=["space-test123"], + ) + + dumped = resource_configuration.model_dump() + restored = DiscoveryOperationResourceConfiguration.model_validate(dumped) + assert isinstance(restored.operation.module, ScriptOperatorConf) + assert restored.operation.module.name == "grid-sweep" + assert restored.operation.module.version == "1.0.0" + assert restored.operation.parameters == {} + + +def test_script_operation_resource_identifier() -> None: + """OperationResource built from ScriptOperatorConf uses script operator id.""" + script_module = ScriptOperatorConf(name="inline-script") + operation_configuration = DiscoveryOperationResourceConfiguration( + operation=DiscoveryOperationConfiguration(module=script_module), + spaces=["space-test123"], + ) + operation = OperationResource( + operationType=DiscoveryOperationEnum.SCRIPT, + operatorIdentifier=script_module.operatorIdentifier, + config=operation_configuration, + ) + + assert operation.operationType == DiscoveryOperationEnum.SCRIPT + assert operation.operatorIdentifier == "script-inline-script-0.1.0" + assert operation.identifier.startswith("operation-script-inline-script-0.1.0-") diff --git a/tests/core/test_space.py b/tests/core/test_space.py index 4a40e0d73..bd5a64b0c 100644 --- a/tests/core/test_space.py +++ b/tests/core/test_space.py @@ -319,3 +319,61 @@ def test_matching_entities_table_virtual_property_with_multiple_values( assert virtual_id in df_with_vp.columns # Aggregated values should be scalar (not lists or None) assert df_with_vp[virtual_id].dropna().apply(lambda x: np.isscalar(x)).all() + + +def test_operation_context_success_lifecycle(pfas_space: DiscoverySpace) -> None: + """operation_context registers the operation and records STARTED then FINISHED/SUCCESS.""" + from unittest.mock import MagicMock + + from orchestrator.core.operation.config import ScriptOperatorConf + from orchestrator.core.operation.resource import ( + OperationExitStateEnum, + OperationResourceEventEnum, + ) + + mock_store = MagicMock() + pfas_space._metadataStore = mock_store + + with pfas_space.operation_context( + name="test-script", + description="Script operation for testing", + metadata={"labels": {"source": "test"}}, + ) as operation_id: + assert operation_id.startswith("operation-script-test-script-") + assert operation_id in pfas_space._verified_operation_ids + + add_call = mock_store.addResourceWithRelationships.call_args + operation = add_call.kwargs["resource"] + assert add_call.kwargs["relatedIdentifiers"] == [pfas_space.uri] + assert isinstance(operation.config.operation.module, ScriptOperatorConf) + assert operation.config.metadata.description == "Script operation for testing" + assert operation.config.metadata.labels == {"source": "test"} + + assert mock_store.updateResource.call_count == 2 + finished_operation = mock_store.updateResource.call_args_list[-1].args[0] + assert finished_operation.status[-2].event == OperationResourceEventEnum.STARTED + assert finished_operation.status[-1].event == OperationResourceEventEnum.FINISHED + assert finished_operation.status[-1].exit_state == OperationExitStateEnum.SUCCESS + + +def test_operation_context_failure_lifecycle(pfas_space: DiscoverySpace) -> None: + """operation_context records FINISHED/FAIL when the wrapped block raises.""" + from unittest.mock import MagicMock + + from orchestrator.core.operation.resource import ( + OperationExitStateEnum, + OperationResourceEventEnum, + ) + + mock_store = MagicMock() + pfas_space._metadataStore = mock_store + + with ( + pytest.raises(RuntimeError, match="boom"), + pfas_space.operation_context(name="failing-script"), + ): + raise RuntimeError("boom") + + finished_operation = mock_store.updateResource.call_args_list[-1].args[0] + assert finished_operation.status[-1].event == OperationResourceEventEnum.FINISHED + assert finished_operation.status[-1].exit_state == OperationExitStateEnum.FAIL From 3e72a7a8289e1c218a60590f99b52b0e50234eb7 Mon Sep 17 00:00:00 2001 From: michaelj Date: Mon, 18 May 2026 20:35:10 +0100 Subject: [PATCH 2/3] refactor(operators): use semantic types --- orchestrator/core/discoveryspace/space.py | 29 +++++++++++++++++------ orchestrator/core/operation/config.py | 14 +++++++---- tests/core/test_operation.py | 18 ++++++++++---- tests/core/test_space.py | 18 ++++++++++++-- 4 files changed, 60 insertions(+), 19 deletions(-) diff --git a/orchestrator/core/discoveryspace/space.py b/orchestrator/core/discoveryspace/space.py index 6db4e47ac..00f78a9bf 100644 --- a/orchestrator/core/discoveryspace/space.py +++ b/orchestrator/core/discoveryspace/space.py @@ -22,6 +22,7 @@ DiscoverySpaceConfiguration, DiscoverySpaceProperties, ) +from orchestrator.core.operation.config import DiscoveryOperationEnum from orchestrator.core.operation.resource import OperationResource from orchestrator.core.resources import CoreResourceKinds from orchestrator.metastore.project import ProjectContext @@ -49,6 +50,9 @@ moduleLogger = logging.getLogger("discoveryspace") +SCRIPT_OPERATION_EXECUTION_LABEL = "script" +SCRIPT_OPERATION_LABEL_KEY = "execution" + def _perform_preflight_checks_for_sample_store_methods( f: Callable[..., Any], # noqa: ANN401 @@ -892,6 +896,7 @@ def operation_context( name: str, description: str | None = None, metadata: dict | None = None, + operation_type: DiscoveryOperationEnum = DiscoveryOperationEnum.SEARCH, ) -> Iterator[str]: """Context manager that registers a script operation and manages its lifecycle. @@ -904,6 +909,9 @@ def operation_context( name: Human-readable script name stored in the operation configuration. description: Optional description for the operation metadata. metadata: Optional extra metadata fields merged into ConfigurationMetadata. + operation_type: Semantic type for the operation (e.g. SEARCH for explore scripts). + Script provenance is always recorded on metadata labels under + ``execution: script``. Yields: The operation resource identifier. @@ -920,7 +928,6 @@ def operation_context( from orchestrator.core.metadata import ConfigurationMetadata from orchestrator.core.operation.config import ( DiscoveryOperationConfiguration, - DiscoveryOperationEnum, DiscoveryOperationResourceConfiguration, ScriptOperatorConf, ) @@ -931,11 +938,19 @@ def operation_context( OperationResourceStatus, ) - script_module = ScriptOperatorConf(name=name) - config_metadata = ConfigurationMetadata(name=name, description=description) - if metadata: - for key, value in metadata.items(): - setattr(config_metadata, key, value) + script_module = ScriptOperatorConf(name=name, operationType=operation_type) + extra_metadata = dict(metadata or {}) + user_labels = extra_metadata.pop("labels", None) or {} + config_metadata = ConfigurationMetadata( + name=name, + description=description, + labels={ + SCRIPT_OPERATION_LABEL_KEY: SCRIPT_OPERATION_EXECUTION_LABEL, + **user_labels, + }, + ) + for key, value in extra_metadata.items(): + setattr(config_metadata, key, value) operation_payload = DiscoveryOperationResourceConfiguration( operation=DiscoveryOperationConfiguration( @@ -947,7 +962,7 @@ def operation_context( ) operation = OperationResource( - operationType=DiscoveryOperationEnum.SCRIPT, + operationType=script_module.operationType, operatorIdentifier=script_module.operatorIdentifier, config=operation_payload, ) diff --git a/orchestrator/core/operation/config.py b/orchestrator/core/operation/config.py index b11d424a5..625a5785a 100644 --- a/orchestrator/core/operation/config.py +++ b/orchestrator/core/operation/config.py @@ -358,11 +358,15 @@ class ScriptOperatorConf(pydantic.BaseModel): model_config = ConfigDict(extra="forbid") name: Annotated[str, pydantic.Field(description="Human-readable script name")] version: Annotated[str, pydantic.Field()] = "0.1.0" - - @property - def operationType(self) -> DiscoveryOperationEnum: - """Return the script operation type.""" - return DiscoveryOperationEnum.SCRIPT + operationType: Annotated[ + DiscoveryOperationEnum, + pydantic.Field( + description=( + "Semantic operation type (e.g. search, characterize). " + "Script provenance is recorded separately via operation metadata labels." + ), + ), + ] = DiscoveryOperationEnum.SEARCH @property def operatorIdentifier(self) -> str: diff --git a/tests/core/test_operation.py b/tests/core/test_operation.py index 5fa69ea75..2b6514a49 100644 --- a/tests/core/test_operation.py +++ b/tests/core/test_operation.py @@ -191,8 +191,12 @@ def test_add_operation_result( def test_script_operator_conf_round_trip() -> None: """ScriptOperatorConf serialises and validates through operation configuration.""" - script_module = ScriptOperatorConf(name="grid-sweep", version="1.0.0") - assert script_module.operationType == DiscoveryOperationEnum.SCRIPT + script_module = ScriptOperatorConf( + name="grid-sweep", + version="1.0.0", + operationType=DiscoveryOperationEnum.SEARCH, + ) + assert script_module.operationType == DiscoveryOperationEnum.SEARCH assert script_module.operatorIdentifier == "script-grid-sweep-1.0.0" operation_configuration = DiscoveryOperationConfiguration( @@ -211,22 +215,26 @@ def test_script_operator_conf_round_trip() -> None: assert isinstance(restored.operation.module, ScriptOperatorConf) assert restored.operation.module.name == "grid-sweep" assert restored.operation.module.version == "1.0.0" + assert restored.operation.module.operationType == DiscoveryOperationEnum.SEARCH assert restored.operation.parameters == {} def test_script_operation_resource_identifier() -> None: """OperationResource built from ScriptOperatorConf uses script operator id.""" - script_module = ScriptOperatorConf(name="inline-script") + script_module = ScriptOperatorConf( + name="inline-script", + operationType=DiscoveryOperationEnum.CHARACTERIZE, + ) operation_configuration = DiscoveryOperationResourceConfiguration( operation=DiscoveryOperationConfiguration(module=script_module), spaces=["space-test123"], ) operation = OperationResource( - operationType=DiscoveryOperationEnum.SCRIPT, + operationType=script_module.operationType, operatorIdentifier=script_module.operatorIdentifier, config=operation_configuration, ) - assert operation.operationType == DiscoveryOperationEnum.SCRIPT + assert operation.operationType == DiscoveryOperationEnum.CHARACTERIZE assert operation.operatorIdentifier == "script-inline-script-0.1.0" assert operation.identifier.startswith("operation-script-inline-script-0.1.0-") diff --git a/tests/core/test_space.py b/tests/core/test_space.py index bd5a64b0c..6b0030892 100644 --- a/tests/core/test_space.py +++ b/tests/core/test_space.py @@ -325,7 +325,14 @@ def test_operation_context_success_lifecycle(pfas_space: DiscoverySpace) -> None """operation_context registers the operation and records STARTED then FINISHED/SUCCESS.""" from unittest.mock import MagicMock - from orchestrator.core.operation.config import ScriptOperatorConf + from orchestrator.core.discoveryspace.space import ( + SCRIPT_OPERATION_EXECUTION_LABEL, + SCRIPT_OPERATION_LABEL_KEY, + ) + from orchestrator.core.operation.config import ( + DiscoveryOperationEnum, + ScriptOperatorConf, + ) from orchestrator.core.operation.resource import ( OperationExitStateEnum, OperationResourceEventEnum, @@ -346,8 +353,15 @@ def test_operation_context_success_lifecycle(pfas_space: DiscoverySpace) -> None operation = add_call.kwargs["resource"] assert add_call.kwargs["relatedIdentifiers"] == [pfas_space.uri] assert isinstance(operation.config.operation.module, ScriptOperatorConf) + assert ( + operation.config.operation.module.operationType == DiscoveryOperationEnum.SEARCH + ) + assert operation.operationType == DiscoveryOperationEnum.SEARCH assert operation.config.metadata.description == "Script operation for testing" - assert operation.config.metadata.labels == {"source": "test"} + assert operation.config.metadata.labels == { + SCRIPT_OPERATION_LABEL_KEY: SCRIPT_OPERATION_EXECUTION_LABEL, + "source": "test", + } assert mock_store.updateResource.call_count == 2 finished_operation = mock_store.updateResource.call_args_list[-1].args[0] From 1add0529b8f0c4b4090d8dea786052c976395c4b Mon Sep 17 00:00:00 2001 From: michaelj Date: Fri, 29 May 2026 09:53:04 +0100 Subject: [PATCH 3/3] chore(orchestrate): address comments from code review --- tests/core/test_space.py | 65 ++++++++++++++++++++++++++-------------- 1 file changed, 42 insertions(+), 23 deletions(-) diff --git a/tests/core/test_space.py b/tests/core/test_space.py index 6b0030892..cc0a7ee23 100644 --- a/tests/core/test_space.py +++ b/tests/core/test_space.py @@ -15,6 +15,12 @@ DiscoverySpace, SpaceInconsistencyError, ) +from orchestrator.core.operation.resource import ( + OperationResource, + OperationResourceEventEnum, + OperationResourceStatus, +) +from orchestrator.core.resources import CoreResourceKinds from orchestrator.core.samplestore.base import ActiveSampleStore from orchestrator.core.samplestore.config import ( SampleStoreConfiguration, @@ -321,10 +327,18 @@ def test_matching_entities_table_virtual_property_with_multiple_values( assert df_with_vp[virtual_id].dropna().apply(lambda x: np.isscalar(x)).all() +def _operation_lifecycle_statuses( + operation: OperationResource, +) -> list[OperationResourceStatus]: + return [ + status + for status in operation.status + if status.event in OperationResourceEventEnum + ] + + def test_operation_context_success_lifecycle(pfas_space: DiscoverySpace) -> None: """operation_context registers the operation and records STARTED then FINISHED/SUCCESS.""" - from unittest.mock import MagicMock - from orchestrator.core.discoveryspace.space import ( SCRIPT_OPERATION_EXECUTION_LABEL, SCRIPT_OPERATION_LABEL_KEY, @@ -335,12 +349,10 @@ def test_operation_context_success_lifecycle(pfas_space: DiscoverySpace) -> None ) from orchestrator.core.operation.resource import ( OperationExitStateEnum, + OperationResource, OperationResourceEventEnum, ) - mock_store = MagicMock() - pfas_space._metadataStore = mock_store - with pfas_space.operation_context( name="test-script", description="Script operation for testing", @@ -349,9 +361,12 @@ def test_operation_context_success_lifecycle(pfas_space: DiscoverySpace) -> None assert operation_id.startswith("operation-script-test-script-") assert operation_id in pfas_space._verified_operation_ids - add_call = mock_store.addResourceWithRelationships.call_args - operation = add_call.kwargs["resource"] - assert add_call.kwargs["relatedIdentifiers"] == [pfas_space.uri] + operation = pfas_space.metadataStore.getResource( + identifier=operation_id, + kind=CoreResourceKinds.OPERATION, + ) + assert isinstance(operation, OperationResource) + assert operation_id in pfas_space.operations["IDENTIFIER"].values assert isinstance(operation.config.operation.module, ScriptOperatorConf) assert ( operation.config.operation.module.operationType == DiscoveryOperationEnum.SEARCH @@ -363,31 +378,35 @@ def test_operation_context_success_lifecycle(pfas_space: DiscoverySpace) -> None "source": "test", } - assert mock_store.updateResource.call_count == 2 - finished_operation = mock_store.updateResource.call_args_list[-1].args[0] - assert finished_operation.status[-2].event == OperationResourceEventEnum.STARTED - assert finished_operation.status[-1].event == OperationResourceEventEnum.FINISHED - assert finished_operation.status[-1].exit_state == OperationExitStateEnum.SUCCESS + lifecycle_statuses = _operation_lifecycle_statuses(operation) + assert lifecycle_statuses[0].event == OperationResourceEventEnum.STARTED + assert lifecycle_statuses[-1].event == OperationResourceEventEnum.FINISHED + assert lifecycle_statuses[-1].exit_state == OperationExitStateEnum.SUCCESS def test_operation_context_failure_lifecycle(pfas_space: DiscoverySpace) -> None: """operation_context records FINISHED/FAIL when the wrapped block raises.""" - from unittest.mock import MagicMock - from orchestrator.core.operation.resource import ( OperationExitStateEnum, + OperationResource, OperationResourceEventEnum, ) - mock_store = MagicMock() - pfas_space._metadataStore = mock_store + failure_message = "Simulated script failure during operation_context lifecycle" with ( - pytest.raises(RuntimeError, match="boom"), - pfas_space.operation_context(name="failing-script"), + pytest.raises(RuntimeError, match=re.escape(failure_message)), + pfas_space.operation_context(name="failing-script") as operation_id, ): - raise RuntimeError("boom") + raise RuntimeError(failure_message) + + operation = pfas_space.metadataStore.getResource( + identifier=operation_id, + kind=CoreResourceKinds.OPERATION, + ) + assert isinstance(operation, OperationResource) - finished_operation = mock_store.updateResource.call_args_list[-1].args[0] - assert finished_operation.status[-1].event == OperationResourceEventEnum.FINISHED - assert finished_operation.status[-1].exit_state == OperationExitStateEnum.FAIL + lifecycle_statuses = _operation_lifecycle_statuses(operation) + assert lifecycle_statuses[0].event == OperationResourceEventEnum.STARTED + assert lifecycle_statuses[-1].event == OperationResourceEventEnum.FINISHED + assert lifecycle_statuses[-1].exit_state == OperationExitStateEnum.FAIL