diff --git a/app/src/organisms/GripperWizardFlows/index.tsx b/app/src/organisms/GripperWizardFlows/index.tsx index aafc36acf9e..219fd687771 100644 --- a/app/src/organisms/GripperWizardFlows/index.tsx +++ b/app/src/organisms/GripperWizardFlows/index.tsx @@ -42,7 +42,9 @@ import type { InstrumentData, MaintenanceRun, CommandData, + RunStatus, } from '@opentrons/api-client' +import { RUN_STATUS_FAILED } from '@opentrons/api-client' import type { Coordinates, CreateCommand } from '@opentrons/shared-data' const RUN_REFETCH_INTERVAL = 5000 @@ -108,6 +110,7 @@ export function GripperWizardFlows( } }, [ maintenanceRunData?.data.id, + maintenanceRunData?.data.status, createdMaintenanceRunId, monitorMaintenanceRunForDeletion, closeFlow, @@ -160,6 +163,7 @@ export function GripperWizardFlows( flowType={flowType} createdMaintenanceRunId={createdMaintenanceRunId} maintenanceRunId={maintenanceRunData?.data.id} + maintenanceRunStatus={maintenanceRunData?.data.status} attachedGripper={attachedGripper} createMaintenanceRun={createTargetedMaintenanceRun} isCreateLoading={isCreateLoading} @@ -183,6 +187,7 @@ export function GripperWizardFlows( interface GripperWizardProps { flowType: GripperWizardFlowType maintenanceRunId?: string + maintenanceRunStatus?: RunStatus createdMaintenanceRunId: string | null attachedGripper: InstrumentData | null createMaintenanceRun: UseMutateFunction< @@ -212,6 +217,7 @@ export const GripperWizard = ( const { flowType, maintenanceRunId, + maintenanceRunStatus, createMaintenanceRun, handleCleanUpAndClose, handleClose, @@ -266,6 +272,7 @@ export const GripperWizard = ( } const sharedProps = { + maintenanceRunStatus, flowType, maintenanceRunId: maintenanceRunId != null && createdMaintenanceRunId === maintenanceRunId @@ -283,7 +290,7 @@ export const GripperWizard = ( let onExit if (currentStep == null) return null let modalContent: JSX.Element =
UNASSIGNED STEP
- if (showConfirmExit) { + if (showConfirmExit && maintenanceRunId !== null) { modalContent = ( ) - } else if (isExiting && errorMessage != null) { + } else if ( + (isExiting && errorMessage != null) || + maintenanceRunStatus === RUN_STATUS_FAILED + ) { onExit = handleClose modalContent = ( ) } else if (currentStep.section === SECTIONS.BEFORE_BEGINNING) { diff --git a/app/src/organisms/ModuleWizardFlows/index.tsx b/app/src/organisms/ModuleWizardFlows/index.tsx index 9cc33d05688..daa1a8b0208 100644 --- a/app/src/organisms/ModuleWizardFlows/index.tsx +++ b/app/src/organisms/ModuleWizardFlows/index.tsx @@ -39,6 +39,7 @@ import { useNotifyDeckConfigurationQuery } from '../../resources/deck_configurat import { useNotifyCurrentMaintenanceRun } from '../../resources/maintenance_runs' import type { AttachedModule, CommandData } from '@opentrons/api-client' +import { RUN_STATUS_FAILED } from '@opentrons/api-client' import type { CreateCommand, CutoutConfig, @@ -271,7 +272,11 @@ export const ModuleWizardFlows = ( })} /> ) - } else if (prepCommandErrorMessage != null || errorMessage != null) { + } else if ( + prepCommandErrorMessage != null || + errorMessage != null || + maintenanceRunData?.data.status === RUN_STATUS_FAILED + ) { modalContent = ( UNASSIGNED STEP - if (isExiting && errorMessage != null) { + if ( + (isExiting && errorMessage != null) || + maintenanceRunData?.data.status === RUN_STATUS_FAILED + ) { modalContent = ( ) } else if (currentStep.section === SECTIONS.BEFORE_BEGINNING) { @@ -395,7 +399,10 @@ export const PipetteWizardFlows = ( let exitWizardButton = onExit if (isCommandMutationLoading || isDeleteLoading) { exitWizardButton = undefined - } else if (errorMessage != null && isExiting) { + } else if ( + (errorMessage != null && isExiting) || + maintenanceRunData?.data.status === RUN_STATUS_FAILED + ) { exitWizardButton = handleClose } else if (showConfirmExit) { exitWizardButton = handleCleanUpAndClose diff --git a/robot-server/robot_server/maintenance_runs/maintenance_run_data_manager.py b/robot-server/robot_server/maintenance_runs/maintenance_run_data_manager.py index 76c355af72a..6c4aa4db205 100644 --- a/robot-server/robot_server/maintenance_runs/maintenance_run_data_manager.py +++ b/robot-server/robot_server/maintenance_runs/maintenance_run_data_manager.py @@ -115,7 +115,9 @@ async def create( state_summary=state_summary, ) - await self._maintenance_runs_publisher.publish_current_maintenance_run_async() + await self._maintenance_runs_publisher.start_publishing_for_maintenance_run( + run_id=run_id, get_state_summary=self._get_state_summary + ) return maintenance_run_data diff --git a/robot-server/robot_server/service/notifications/publishers/maintenance_runs_publisher.py b/robot-server/robot_server/service/notifications/publishers/maintenance_runs_publisher.py index 80285cb6452..6be16d5c390 100644 --- a/robot-server/robot_server/service/notifications/publishers/maintenance_runs_publisher.py +++ b/robot-server/robot_server/service/notifications/publishers/maintenance_runs_publisher.py @@ -1,20 +1,69 @@ +from dataclasses import dataclass +from typing import Callable, Optional from fastapi import Depends +from opentrons.protocol_engine.state.state_summary import StateSummary +from opentrons.protocol_engine.types import EngineStatus from server_utils.fastapi_utils.app_state import ( AppState, AppStateAccessor, get_app_state, ) from ..notification_client import NotificationClient, get_notification_client +from ..publisher_notifier import PublisherNotifier, get_pe_publisher_notifier from .. import topics +@dataclass +class _RunHooks: + """Generated during a protocol run. Utilized by MaintenanceRunsPublisher.""" + + run_id: str + get_state_summary: Callable[[str], Optional[StateSummary]] + + +@dataclass +class _EngineStateSlice: + """Protocol Engine state relevant to MaintenanceRunsPublisher.""" + + state_summary_status: Optional[EngineStatus] = None + + class MaintenanceRunsPublisher: """Publishes maintenance run topics.""" - def __init__(self, client: NotificationClient) -> None: + def __init__( + self, client: NotificationClient, publisher_notifier: PublisherNotifier + ) -> None: """Returns a configured Maintenance Runs Publisher.""" self._client = client + self._run_hooks: Optional[_RunHooks] = None + self._engine_state_slice: Optional[_EngineStateSlice] = None + + publisher_notifier.register_publish_callbacks( + [ + self._handle_engine_status_change, + ] + ) + + async def start_publishing_for_maintenance_run( + self, + run_id: str, + get_state_summary: Callable[[str], Optional[StateSummary]], + ) -> None: + """Initialize RunsPublisher with necessary information derived from the current run. + + Args: + run_id: ID of the current run. + get_state_summary: Callback to get the current run's state summary, if any. + """ + self._run_hooks = _RunHooks( + run_id=run_id, + get_state_summary=get_state_summary, + ) + self._engine_state_slice = _EngineStateSlice() + + await self.publish_current_maintenance_run_async() async def publish_current_maintenance_run_async( self, @@ -30,6 +79,21 @@ def publish_current_maintenance_run( """Publishes the equivalent of GET /maintenance_run/current_run""" self._client.publish_advise_refetch(topic=topics.MAINTENANCE_RUNS_CURRENT_RUN) + async def _handle_engine_status_change(self) -> None: + """Publish a refetch flag if the engine status has changed.""" + if self._run_hooks is not None and self._engine_state_slice is not None: + new_state_summary = self._run_hooks.get_state_summary( + self._run_hooks.run_id + ) + + if ( + new_state_summary is not None + and self._engine_state_slice.state_summary_status + != new_state_summary.status + ): + await self.publish_current_maintenance_run_async() + self._engine_state_slice.state_summary_status = new_state_summary.status + _maintenance_runs_publisher_accessor: AppStateAccessor[ MaintenanceRunsPublisher @@ -39,6 +103,7 @@ def publish_current_maintenance_run( async def get_maintenance_runs_publisher( app_state: AppState = Depends(get_app_state), notification_client: NotificationClient = Depends(get_notification_client), + publisher_notifier: PublisherNotifier = Depends(get_pe_publisher_notifier), ) -> MaintenanceRunsPublisher: """Get a singleton MaintenanceRunsPublisher to publish maintenance run topics.""" maintenance_runs_publisher = _maintenance_runs_publisher_accessor.get_from( @@ -47,7 +112,7 @@ async def get_maintenance_runs_publisher( if maintenance_runs_publisher is None: maintenance_runs_publisher = MaintenanceRunsPublisher( - client=notification_client + client=notification_client, publisher_notifier=publisher_notifier ) _maintenance_runs_publisher_accessor.set_on( app_state, maintenance_runs_publisher diff --git a/robot-server/tests/service/notifications/publishers/test_maintenance_runs_publisher.py b/robot-server/tests/service/notifications/publishers/test_maintenance_runs_publisher.py index bfdbbd26312..ea4a02a1e5f 100644 --- a/robot-server/tests/service/notifications/publishers/test_maintenance_runs_publisher.py +++ b/robot-server/tests/service/notifications/publishers/test_maintenance_runs_publisher.py @@ -1,22 +1,30 @@ """Tests for the maintenance runs publisher.""" import pytest -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock from robot_server.service.notifications import MaintenanceRunsPublisher, topics +from robot_server.service.notifications.notification_client import NotificationClient +from robot_server.service.notifications.publisher_notifier import PublisherNotifier @pytest.fixture -def notification_client() -> AsyncMock: +def notification_client() -> Mock: """Mocked notification client.""" - return AsyncMock() + return Mock(spec_set=NotificationClient) + + +@pytest.fixture +def publisher_notifier() -> Mock: + """Mocked publisher notifier.""" + return Mock(spec_set=PublisherNotifier) @pytest.fixture def maintenance_runs_publisher( - notification_client: AsyncMock, + notification_client: Mock, publisher_notifier: Mock ) -> MaintenanceRunsPublisher: """Instantiate MaintenanceRunsPublisher.""" - return MaintenanceRunsPublisher(notification_client) + return MaintenanceRunsPublisher(notification_client, publisher_notifier) @pytest.mark.asyncio