Skip to content

Commit 176f130

Browse files
mjhuffddcc4
authored andcommitted
fix(robot-server, api): Standardize command slice behavior (#18203)
Closes EXEC-1466 Currently, when requesting a command slice or a command error slice via the HTTP API, the exact behavior varies depending on the run status, the “current” status of the run, and which parameters are (or are not) supplied. The behavior varies in the following way: * When invoking get_slice with identical cursor and pageLength parameters, there is inconsistency in the number of returned commands when the run is in a recovery state. * When a run is historical, an unsupplied cursor but a supplied pageLength returns the most recent pageLength commands, however, when a run is current, only the most recently running/executed command is returned. * The above always applies to run command errors. This commit fixes those issues by standardizing behavior in a manner that is most useful given typical client access patterns: if a cursor is not supplied but a pageLength is supplied, we return the pageLength most recent commands and command errors (so all behavior reflects the current behavior if the command slice is derived from a historical run). After doing a complete client-side audit, the client actually seemingly expects the API to behave this way, and has fortunately worked by chance. (cherry picked from commit 2c374ff)
1 parent 3a109d5 commit 176f130

File tree

8 files changed

+147
-61
lines changed

8 files changed

+147
-61
lines changed

api/src/opentrons/protocol_engine/state/commands.py

Lines changed: 14 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -611,49 +611,35 @@ def get_slice(
611611
"""Get a subset of commands around a given cursor.
612612
613613
If the cursor is omitted, a cursor will be selected automatically
614-
based on the currently running or most recently executed command.
614+
based on the currently running or most recently executed command,
615+
and the slice of commands returned is the previous `length` commands
616+
inclusive of the currently running or most recently executed command.
615617
"""
616618
command_ids = self._state.command_history.get_filtered_command_ids(
617619
include_fixit_commands=include_fixit_commands
618620
)
619-
running_command = self._state.command_history.get_running_command()
620-
queued_command_ids = self._state.command_history.get_queue_ids()
621621
total_length = len(command_ids)
622622

623-
# TODO(mm, 2024-05-17): This looks like it's attempting to do the same thing
624-
# as self.get_current(), but in a different way. Can we unify them?
625623
if cursor is None:
626-
if running_command is not None:
627-
cursor = running_command.index
628-
elif len(queued_command_ids) > 0:
629-
# Get the most recently executed command,
630-
# which we can find just before the first queued command.
631-
cursor = (
632-
self._state.command_history.get(queued_command_ids.head()).index - 1
633-
)
634-
elif (
635-
self._state.run_result
636-
and self._state.run_result == RunResult.FAILED
637-
and self._state.failed_command
638-
):
639-
# Currently, if the run fails, we mark all the commands we didn't
640-
# reach as failed. This makes command status alone insufficient to
641-
# find the most recent command that actually executed, so we need to
642-
# store that separately.
643-
cursor = self._state.failed_command.index
624+
current_pointer = self.get_current()
625+
626+
if current_pointer is not None:
627+
cursor = current_pointer.index
644628
else:
645-
cursor = total_length - length
629+
cursor = total_length - 1
630+
631+
cursor = max(cursor - length + 1, 0)
646632

647633
# start is inclusive, stop is exclusive
648-
actual_cursor = max(0, min(cursor, total_length - 1))
649-
stop = min(total_length, actual_cursor + length)
634+
start = max(0, min(cursor, total_length - 1))
635+
stop = min(total_length, start + length)
650636
commands = self._state.command_history.get_slice(
651-
start=actual_cursor, stop=stop, command_ids=command_ids
637+
start=start, stop=stop, command_ids=command_ids
652638
)
653639

654640
return CommandSlice(
655641
commands=commands,
656-
cursor=actual_cursor,
642+
cursor=start,
657643
total_length=total_length,
658644
)
659645

api/tests/opentrons/protocol_engine/state/test_command_view_old.py

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1000,8 +1000,8 @@ def test_get_slice_default_cursor_failed_command() -> None:
10001000
result = subject.get_slice(cursor=None, length=3, include_fixit_commands=True)
10011001

10021002
assert result == CommandSlice(
1003-
commands=[command_3, command_4],
1004-
cursor=2,
1003+
commands=[command_2, command_3, command_4],
1004+
cursor=1,
10051005
total_length=4,
10061006
)
10071007

@@ -1022,14 +1022,14 @@ def test_get_slice_default_cursor_running() -> None:
10221022
result = subject.get_slice(cursor=None, length=2, include_fixit_commands=True)
10231023

10241024
assert result == CommandSlice(
1025-
commands=[command_3, command_4],
1026-
cursor=2,
1025+
commands=[command_2, command_3],
1026+
cursor=1,
10271027
total_length=5,
10281028
)
10291029

10301030

10311031
def test_get_slice_without_fixit() -> None:
1032-
"""It should select a cursor based on the running command, if present."""
1032+
"""It should filter out fixit commands when requested."""
10331033
command_1 = create_succeeded_command(command_id="command-id-1")
10341034
command_2 = create_succeeded_command(command_id="command-id-2")
10351035
command_3 = create_running_command(command_id="command-id-3")
@@ -1071,3 +1071,44 @@ def test_get_slice_without_fixit() -> None:
10711071
cursor=0,
10721072
total_length=5,
10731073
)
1074+
1075+
1076+
def test_get_slice_large_length() -> None:
1077+
"""It should handle cases where length is larger than available commands."""
1078+
command_1 = create_succeeded_command(command_id="command-id-1")
1079+
command_2 = create_succeeded_command(command_id="command-id-2")
1080+
command_3 = create_running_command(command_id="command-id-3")
1081+
1082+
subject = get_command_view(
1083+
commands=[command_1, command_2, command_3],
1084+
running_command_id="command-id-3",
1085+
)
1086+
1087+
result = subject.get_slice(cursor=None, length=10, include_fixit_commands=True)
1088+
1089+
assert result == CommandSlice(
1090+
commands=[command_1, command_2, command_3],
1091+
cursor=0,
1092+
total_length=3,
1093+
)
1094+
1095+
1096+
def test_get_slice_explicit_cursor_with_length() -> None:
1097+
"""It should use the cursor as the start position when explicitly provided."""
1098+
command_1 = create_succeeded_command(command_id="command-id-1")
1099+
command_2 = create_succeeded_command(command_id="command-id-2")
1100+
command_3 = create_succeeded_command(command_id="command-id-3")
1101+
command_4 = create_succeeded_command(command_id="command-id-4")
1102+
command_5 = create_succeeded_command(command_id="command-id-5")
1103+
1104+
subject = get_command_view(
1105+
commands=[command_1, command_2, command_3, command_4, command_5],
1106+
)
1107+
1108+
result = subject.get_slice(cursor=1, length=3, include_fixit_commands=True)
1109+
1110+
assert result == CommandSlice(
1111+
commands=[command_2, command_3, command_4],
1112+
cursor=1,
1113+
total_length=5,
1114+
)

robot-server/robot_server/commands/router.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,9 @@ async def get_commands_list(
135135
description=(
136136
"The starting index of the desired first command in the list."
137137
" If unspecified, a cursor will be selected automatically"
138-
" based on the currently running or most recently executed command."
138+
" based on the currently running or most recently executed command, "
139+
" and the slice of commands returned is the previous `pageLength` commands"
140+
" inclusive of the currently running or most recently executed command."
139141
),
140142
),
141143
] = None,

robot-server/robot_server/maintenance_runs/router/commands_router.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,9 @@ async def get_run_commands(
197197
description=(
198198
"The starting index of the desired first command in the list."
199199
" If unspecified, a cursor will be selected automatically"
200-
" based on the currently running or most recently executed command."
200+
" based on the currently running or most recently executed command, "
201+
" and the slice of commands returned is the previous `length` commands"
202+
" inclusive of the currently running or most recently executed command."
201203
),
202204
),
203205
] = None,

robot-server/robot_server/runs/router/base_router.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -473,7 +473,8 @@ async def get_run_commands_error(
473473
description=(
474474
"The starting index of the desired first command error in the list."
475475
" If unspecified, a cursor will be selected automatically"
476-
" based on the last error added."
476+
" based on the last error added, and the slice of errors returned "
477+
" is the previous `pageLength` errors."
477478
),
478479
),
479480
] = None,
@@ -490,12 +491,9 @@ async def get_run_commands_error(
490491
all_errors_count = run_data_manager.get_command_errors_count(run_id=runId)
491492

492493
if cursor is None:
493-
if all_errors_count > 0:
494-
# Get the most recent error,
495-
# which we can find just at the end of the list.
496-
cursor = all_errors_count - 1
497-
else:
498-
cursor = 0
494+
cursor = max(all_errors_count - 1, 0)
495+
cursor = max(cursor - pageLength + 1, 0)
496+
cursor = min(cursor, all_errors_count)
499497

500498
command_error_slice = run_data_manager.get_command_error_slice(
501499
run_id=runId,

robot-server/robot_server/runs/router/commands_router.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,9 @@ async def get_run_commands(
276276
description=(
277277
"The starting index of the desired first command in the list."
278278
" If unspecified, a cursor will be selected automatically"
279-
" based on the currently running or most recently executed command."
279+
" based on the currently running or most recently executed command, "
280+
" and the slice of commands returned is the previous `pageLength` commands"
281+
" inclusive of the currently running or most recently executed command."
280282
),
281283
),
282284
] = None,

robot-server/tests/integration/http_api/runs/test_json_v6_run_failure.tavern.yaml

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,36 @@ stages:
9191
key: !anystr
9292
createdAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))"
9393
meta:
94-
cursor: 3
94+
cursor: 0
9595
totalLength: 4
9696
data:
97+
- id: !anystr
98+
key: !anystr
99+
commandType: home
100+
createdAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))"
101+
startedAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))"
102+
completedAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))"
103+
status: succeeded
104+
params: !anydict
105+
notes: !anylist
106+
- id: !anystr
107+
key: !anystr
108+
commandType: loadLabware
109+
createdAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))"
110+
startedAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))"
111+
completedAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))"
112+
status: succeeded
113+
params: !anydict
114+
notes: !anylist
115+
- id: !anystr
116+
key: !anystr
117+
commandType: loadPipette
118+
createdAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))"
119+
startedAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))"
120+
completedAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))"
121+
status: succeeded
122+
params: !anydict
123+
notes: !anylist
97124
- id: !anystr
98125
key: !anystr
99126
commandType: aspirate
@@ -115,12 +142,6 @@ stages:
115142
pipetteId: pipetteId
116143
labwareId: tipRackId
117144
wellName: A1
118-
wellLocation:
119-
origin: bottom
120-
offset:
121-
x: 0
122-
y: 0
123-
z: 1
124-
volumeOffset: 0
125-
flowRate: 3.78
126-
volume: 100
145+
wellLocation: !anydict
146+
flowRate: !anyfloat
147+
volume: !anyfloat

robot-server/tests/integration/http_api/runs/test_papi_v2_run_failure.tavern.yaml

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -92,17 +92,51 @@ stages:
9292
key: !anystr
9393
createdAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))"
9494
meta:
95-
cursor: 3
95+
cursor: 0
9696
totalLength: 4
9797
data:
9898
- id: !anystr
9999
key: !anystr
100+
commandType: home
101+
createdAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))"
102+
startedAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))"
103+
completedAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))"
104+
status: succeeded
105+
params: !anydict
106+
notes: !anylist
107+
- id: !re_fullmatch "commands\\.LOAD_LABWARE-\\d+"
108+
key: !re_fullmatch "commands\\.LOAD_LABWARE-\\d+"
109+
commandType: loadLabware
110+
createdAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))"
111+
startedAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))"
112+
completedAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))"
113+
status: succeeded
114+
params:
115+
location:
116+
slotName: "1"
117+
loadName: opentrons_96_tiprack_300ul
118+
namespace: opentrons
119+
version: 1
120+
notes: !anylist
121+
- id: !re_fullmatch "commands\\.LOAD_PIPETTE-\\d+"
122+
key: !re_fullmatch "commands\\.LOAD_PIPETTE-\\d+"
123+
commandType: loadPipette
124+
createdAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))"
125+
startedAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))"
126+
completedAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))"
127+
status: succeeded
128+
params:
129+
pipetteName: p300_single
130+
mount: right
131+
notes: !anylist
132+
- id: !re_fullmatch "command\\.ASPIRATE-\\d+"
133+
key: !re_fullmatch "command\\.ASPIRATE-\\d+"
100134
commandType: aspirate
101135
createdAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))"
102136
startedAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))"
103137
completedAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))"
104138
status: failed
105-
notes: []
139+
notes: !anylist
106140
error:
107141
id: !anystr
108142
errorType: LegacyContextCommandError
@@ -119,9 +153,9 @@ stages:
119153
wellLocation:
120154
origin: top
121155
offset:
122-
x: 0
123-
y: 0
124-
z: 0
125-
volumeOffset: 0
126-
flowRate: 150
127-
volume: 100
156+
x: !anyfloat
157+
y: !anyfloat
158+
z: !anyfloat
159+
volumeOffset: !anyfloat
160+
flowRate: !anyfloat
161+
volume: !anyfloat

0 commit comments

Comments
 (0)