Skip to content

Commit

Permalink
feat(robot-server): Return a currentlyRecoveringFrom command pointer (
Browse files Browse the repository at this point in the history
  • Loading branch information
SyntaxColoring authored May 21, 2024
1 parent f63483d commit 142fad6
Show file tree
Hide file tree
Showing 17 changed files with 231 additions and 97 deletions.
22 changes: 12 additions & 10 deletions api-client/src/runs/commands/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,18 @@ export interface CommandDetail {
}

export interface CommandsLinks {
current: {
// link to the currently executing command
href: string
meta: {
runId: string
commandId: string
key: string
createdAt: string
index: number
}
current?: CommandsLink | null
currentlyRecoveringFrom?: CommandsLink | null
}

interface CommandsLink {
href: string
meta: {
runId: string
commandId: string
key: string
createdAt: string
index: number
}
}

Expand Down
4 changes: 2 additions & 2 deletions api/src/opentrons/protocol_engine/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
CommandType,
CommandIntent,
)
from .state import State, StateView, StateSummary, CommandSlice, CurrentCommand, Config
from .state import State, StateView, StateSummary, CommandSlice, CommandPointer, Config
from .plugins import AbstractPlugin

from .types import (
Expand Down Expand Up @@ -85,7 +85,7 @@
"State",
"StateView",
"CommandSlice",
"CurrentCommand",
"CommandPointer",
# public value interfaces and models
"LabwareOffset",
"LabwareOffsetCreate",
Expand Down
4 changes: 2 additions & 2 deletions api/src/opentrons/protocol_engine/state/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
CommandState,
CommandView,
CommandSlice,
CurrentCommand,
CommandPointer,
)
from .command_history import CommandEntry
from .labware import LabwareState, LabwareView
Expand Down Expand Up @@ -39,7 +39,7 @@
"CommandState",
"CommandView",
"CommandSlice",
"CurrentCommand",
"CommandPointer",
"CommandEntry",
# labware state and values
"LabwareState",
Expand Down
51 changes: 33 additions & 18 deletions api/src/opentrons/protocol_engine/state/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,8 @@ class CommandSlice:


@dataclass(frozen=True)
class CurrentCommand:
"""The "current" command's ID and index in the overall commands list."""
class CommandPointer:
"""Brief info about a command and where to find it."""

command_id: str
command_key: str
Expand Down Expand Up @@ -593,28 +593,28 @@ def get_queue_ids(self) -> OrderedSet[str]:
"""Get the IDs of all queued protocol commands, in FIFO order."""
return self._state.command_history.get_queue_ids()

def get_current(self) -> Optional[CurrentCommand]:
def get_current(self) -> Optional[CommandPointer]:
"""Return the "current" command, if any.
The "current" command is the command that is currently executing,
or the most recent command to have completed.
"""
running_command = self._state.command_history.get_running_command()
if running_command:
return CurrentCommand(
return CommandPointer(
command_id=running_command.command.id,
command_key=running_command.command.key,
created_at=running_command.command.createdAt,
index=running_command.index,
)

final_command = self.get_final_command()
if final_command:
return CurrentCommand(
command_id=final_command.command.id,
command_key=final_command.command.key,
created_at=final_command.command.createdAt,
index=final_command.index,
most_recently_finalized_command = self.get_most_recently_finalized_command()
if most_recently_finalized_command:
return CommandPointer(
command_id=most_recently_finalized_command.command.id,
command_key=most_recently_finalized_command.command.key,
created_at=most_recently_finalized_command.command.createdAt,
index=most_recently_finalized_command.index,
)

return None
Expand Down Expand Up @@ -678,7 +678,7 @@ def get_is_running(self) -> bool:
"""Get whether the protocol is running & queued commands should be executed."""
return self._state.queue_status == QueueStatus.RUNNING

def get_final_command(self) -> Optional[CommandEntry]:
def get_most_recently_finalized_command(self) -> Optional[CommandEntry]:
"""Get the most recent command that has reached its final `status`. See get_command_is_final."""
run_requested_to_stop = self._state.run_result is not None

Expand All @@ -691,22 +691,22 @@ def get_final_command(self) -> Optional[CommandEntry]:
else:
return self._state.command_history.get_prev(tail_command.command.id)
else:
final_command = self._state.command_history.get_terminal_command()
most_recently_finalized = self._state.command_history.get_terminal_command()
# This iteration is effectively O(1) as we'll only ever have to iterate one or two times at most.
while final_command is not None:
while most_recently_finalized is not None:
next_command = self._state.command_history.get_next(
final_command.command.id
most_recently_finalized.command.id
)
if (
next_command is not None
and next_command.command.status != CommandStatus.QUEUED
and next_command.command.status != CommandStatus.RUNNING
):
final_command = next_command
most_recently_finalized = next_command
else:
break

return final_command
return most_recently_finalized

def get_command_is_final(self, command_id: str) -> bool:
"""Get whether a given command has reached its final `status`.
Expand Down Expand Up @@ -751,9 +751,24 @@ def get_all_commands_final(self) -> bool:

return no_command_running and no_command_to_execute

def get_recovery_target(self) -> Optional[CommandPointer]:
"""Return the command currently undergoing error recovery, if any."""
recovery_target_command_id = self._state.recovery_target_command_id
if recovery_target_command_id is None:
return None
else:
entry = self._state.command_history.get(recovery_target_command_id)
return CommandPointer(
command_id=entry.command.id,
command_key=entry.command.key,
created_at=entry.command.createdAt,
index=entry.index,
)

def get_recovery_in_progress_for_command(self, command_id: str) -> bool:
"""Return whether the given command failed and its error recovery is in progress."""
return self._state.recovery_target_command_id == command_id
pointer = self.get_recovery_target()
return pointer is not None and pointer.command_id == command_id

def raise_fatal_command_error(self) -> None:
"""Raise the run's fatal command error, if there was one, as an exception.
Expand Down
14 changes: 11 additions & 3 deletions api/tests/opentrons/protocol_engine/state/test_command_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,8 +356,8 @@ def test_error_recovery_type_tracking() -> None:
assert view.get_error_recovery_type("c2") == ErrorRecoveryType.FAIL_RUN


def test_get_recovery_in_progress_for_command() -> None:
"""It should return whether error recovery is in progress for the given command."""
def test_recovery_target_tracking() -> None:
"""It should keep track of the command currently undergoing error recovery."""
subject = CommandStore(config=_make_config(), is_door_open=False)
subject_view = CommandView(subject.state)

Expand All @@ -382,12 +382,16 @@ def test_get_recovery_in_progress_for_command() -> None:
subject.handle_action(fail_1)

# c1 failed recoverably and we're currently recovering from it.
recovery_target = subject_view.get_recovery_target()
assert recovery_target is not None
assert recovery_target.command_id == "c1"
assert subject_view.get_recovery_in_progress_for_command("c1")

resume_from_1_recovery = actions.ResumeFromRecoveryAction()
subject.handle_action(resume_from_1_recovery)

# c1 failed recoverably, but we've already completed its recovery.
assert subject_view.get_recovery_target() is None
assert not subject_view.get_recovery_in_progress_for_command("c1")

queue_2 = actions.QueueCommandAction(
Expand All @@ -411,6 +415,9 @@ def test_get_recovery_in_progress_for_command() -> None:
subject.handle_action(fail_2)

# c2 failed recoverably and we're currently recovering from it.
recovery_target = subject_view.get_recovery_target()
assert recovery_target is not None
assert recovery_target.command_id == "c2"
assert subject_view.get_recovery_in_progress_for_command("c2")
# ...and that means we're *not* currently recovering from c1,
# even though it failed recoverably before.
Expand Down Expand Up @@ -439,7 +446,8 @@ def test_get_recovery_in_progress_for_command() -> None:
subject.handle_action(fail_3)

# c3 failed, but not recoverably.
assert not subject_view.get_recovery_in_progress_for_command("c2")
assert subject_view.get_recovery_target() is None
assert not subject_view.get_recovery_in_progress_for_command("c3")


def test_final_state_after_estop() -> None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
CommandState,
CommandView,
CommandSlice,
CurrentCommand,
CommandPointer,
RunResult,
QueueStatus,
)
Expand Down Expand Up @@ -846,7 +846,7 @@ def test_get_current() -> None:
queued_command_ids=[],
commands=[command],
)
assert subject.get_current() == CurrentCommand(
assert subject.get_current() == CommandPointer(
index=0,
command_id="command-id",
command_key="command-key",
Expand All @@ -866,7 +866,7 @@ def test_get_current() -> None:
subject = get_command_view(commands=[command_1, command_2])
subject.state.command_history._set_terminal_command_id(command_1.id)

assert subject.get_current() == CurrentCommand(
assert subject.get_current() == CommandPointer(
index=1,
command_id="command-id-2",
command_key="key-2",
Expand All @@ -886,7 +886,7 @@ def test_get_current() -> None:
subject = get_command_view(commands=[command_1, command_2])
subject.state.command_history._set_terminal_command_id(command_1.id)

assert subject.get_current() == CurrentCommand(
assert subject.get_current() == CommandPointer(
index=1,
command_id="command-id-2",
command_key="key-2",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
LabwareOffsetCreate,
StateSummary,
CommandSlice,
CurrentCommand,
CommandPointer,
Command,
)

Expand Down Expand Up @@ -183,7 +183,7 @@ def get_commands_slice(
)
return the_slice

def get_current_command(self, run_id: str) -> Optional[CurrentCommand]:
def get_current_command(self, run_id: str) -> Optional[CommandPointer]:
"""Get the "current" command, if any.
See `ProtocolEngine.state_view.commands.get_current()` for the definition
Expand All @@ -194,7 +194,25 @@ def get_current_command(self, run_id: str) -> Optional[CurrentCommand]:
"""
if self._engine_store.current_run_id == run_id:
return self._engine_store.engine.state_view.commands.get_current()
return None
else:
# todo(mm, 2024-05-20):
# For historical runs to behave consistently with the current run,
# this should be the most recently completed command, not `None`.
return None

def get_recovery_target_command(self, run_id: str) -> Optional[CommandPointer]:
"""Get the current error recovery target.
See `ProtocolEngine.state_view.commands.get_recovery_target()`.
Args:
run_id: ID of the run.
"""
if self._engine_store.current_run_id == run_id:
return self._engine_store.engine.state_view.commands.get_recovery_target()
else:
# Historical runs can't have any ongoing error recovery.
return None

def get_command(self, run_id: str, command_id: str) -> Command:
"""Get a run's command by ID.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from fastapi import APIRouter, Depends, Query, status

from opentrons.protocol_engine import (
CommandPointer,
ProtocolEngine,
commands as pe_commands,
)
Expand Down Expand Up @@ -220,6 +221,7 @@ async def get_run_commands(
raise RunNotFound(detail=str(e)).as_error(status.HTTP_404_NOT_FOUND) from e

current_command = run_data_manager.get_current_command(run_id=runId)
recovery_target_command = run_data_manager.get_recovery_target_command(run_id=runId)

data = [
MaintenanceRunCommandSummary.construct(
Expand All @@ -242,19 +244,10 @@ async def get_run_commands(
totalLength=command_slice.total_length,
)

links = CommandCollectionLinks()

if current_command is not None:
links.current = CommandLink(
href=f"/runs/{runId}/commands/{current_command.command_id}",
meta=CommandLinkMeta(
runId=runId,
commandId=current_command.command_id,
index=current_command.index,
key=current_command.command_key,
createdAt=current_command.created_at,
),
)
links = CommandCollectionLinks.construct(
current=_make_command_link(runId, current_command),
currentlyRecoveringFrom=_make_command_link(runId, recovery_target_command),
)

return await PydanticResponse.create(
content=MultiBody.construct(data=data, meta=meta, links=links),
Expand Down Expand Up @@ -302,3 +295,22 @@ async def get_run_command(
content=SimpleBody.construct(data=command),
status_code=status.HTTP_200_OK,
)


def _make_command_link(
run_id: str, command_pointer: Optional[CommandPointer]
) -> Optional[CommandLink]:
return (
CommandLink.construct(
href=f"/maintenance_runs/{run_id}/commands/{command_pointer.command_id}",
meta=CommandLinkMeta(
runId=run_id,
commandId=command_pointer.command_id,
index=command_pointer.index,
key=command_pointer.command_key,
createdAt=command_pointer.created_at,
),
)
if command_pointer is not None
else None
)
Loading

0 comments on commit 142fad6

Please sign in to comment.