From c805a73803bb8dfb57819eb73e5ae65517ed54d9 Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Thu, 23 Apr 2026 13:16:22 +0200 Subject: [PATCH 01/21] Raise ToolInputsNotReady for unpopulated structured_like target When a tool output is declared ``structured_like=`` and the user maps the tool over an empty collection, param_combinations is empty and ExecutionTracker.example_params falls back to the raw param_template. The non-mapped collection's batch wrapper there is never substituted with an HDCA, so sliced_input_collection_structure crashes with "Referenced input parameter is not a collection." The real trigger observed in production was an upstream collection that had not finished populating yet. Move the representative-params logic onto MappingParameters so the fallback branch resolves batch wrappers (and bare {src,id} refs) to HDCA/DCE ORM objects, and raise ToolInputsNotReadyException when the referenced collection's populated_optimized is False. The scheduler already handles this exception by retrying, matching the behavior __expand_collection_parameter has for mapped-over HDCAs. Also switch sliced_input_collection_structure to derive the collection_type via get_collection(input_collection) so the DCE path stays consistent with the existing get_collection call that follows. Fixes GALAXY-MAIN-4KSCZZZ0015NC. --- lib/galaxy/tools/execute.py | 81 ++++++++++++++-- lib/galaxy_test/api/test_tool_execute.py | 21 +++++ ...tion_mapped_over_empty_structured_like.xml | 13 +++ test/functional/tools/sample_tool_conf.xml | 1 + .../test_structured_like_unpopulated.py | 93 +++++++++++++++++++ 5 files changed, 200 insertions(+), 9 deletions(-) create mode 100644 test/functional/tools/collection_mapped_over_empty_structured_like.xml create mode 100644 test/integration/test_structured_like_unpopulated.py diff --git a/lib/galaxy/tools/execute.py b/lib/galaxy/tools/execute.py index e73fa0315bd..d1f1311d9d8 100644 --- a/lib/galaxy/tools/execute.py +++ b/lib/galaxy/tools/execute.py @@ -21,7 +21,10 @@ from boltons.iterutils import remap from packaging.version import Version from galaxy import model -from galaxy.exceptions import ToolInputsNotOKException +from galaxy.exceptions import ( + ToolInputsNotOKException, + ToolInputsNotReadyException, +) from galaxy.model import ToolRequest from galaxy.model.dataset_collections.matching import MatchingCollections from galaxy.model.dataset_collections.structure import ( @@ -41,6 +44,7 @@ from galaxy.tools.execution_helpers import ( ToolExecutionCache, ) from galaxy.tools.parameters.workflow_utils import is_runtime_value +from galaxy.work.context import WorkRequestContext from ._types import ( ToolRequestT, ToolStateJobInstancePopulatedT, @@ -87,6 +91,71 @@ class MappingParameters(NamedTuple): assert self.validated_param_template is not None assert self.validated_param_combinations is not None + def example_params(self, trans: WorkRequestContext) -> ToolStateJobInstancePopulatedT: + """Representative per-job params for output-structure determination. + + Normally returns ``param_combinations[0]``. When the request + produces zero jobs (e.g. mapping over an empty collection), + falls back to a resolved copy of ``param_template``: batch + wrappers and raw ``{"src", "id"}`` refs are replaced with + HDCA/DCE ORM objects. Raises :class:`ToolInputsNotReadyException` + if a referenced collection exists but is not populated yet, so + the scheduler retries instead of surfacing the cryptic + "Referenced input parameter is not a collection." error. + """ + if self.param_combinations: + return self.param_combinations[0] + return _resolve_template(self.param_template, trans) + + +def _resolve_template(template: ToolRequestT, trans: WorkRequestContext) -> ToolStateJobInstancePopulatedT: + return {key: _resolve_template_value(value, trans) for key, value in template.items()} + + +def _resolve_template_value(value: Any, trans: WorkRequestContext) -> Any: + if isinstance(value, dict): + values = value.get("values") + if ( + isinstance(values, list) + and values + and isinstance(values[0], dict) + and "src" in values[0] + and "id" in values[0] + ): + return _resolve_collection_ref(values[0], trans, raw_fallback=value) + if "src" in value and "id" in value: + return _resolve_collection_ref(value, trans, raw_fallback=value) + return {k: _resolve_template_value(v, trans) for k, v in value.items()} + if isinstance(value, list): + return [_resolve_template_value(v, trans) for v in value] + return value + + +def _resolve_collection_ref( + ref: dict[str, Any], + trans: WorkRequestContext, + raw_fallback: Any, +) -> Union[model.HistoryDatasetCollectionAssociation, model.DatasetCollectionElement, Any]: + src = ref.get("src") + rid = ref.get("id") + if rid is None or src not in ("hdca", "dce"): + return raw_fallback + decoded = rid if isinstance(rid, int) else trans.security.decode_id(rid) + sa_session = trans.sa_session + if src == "hdca": + hdca = sa_session.get(model.HistoryDatasetCollectionAssociation, decoded) + if hdca is None: + return raw_fallback + if not hdca.collection.populated_optimized: + raise ToolInputsNotReadyException("An input collection is not populated.") + return hdca + dce = sa_session.get(model.DatasetCollectionElement, decoded) + if dce is None or dce.child_collection is None: + return raw_fallback + if not dce.child_collection.populated_optimized: + raise ToolInputsNotReadyException("An input collection is not populated.") + return dce + def execute_async( trans, @@ -416,13 +485,7 @@ class ExecutionTracker: @property def example_params(self): - if self.mapping_params.param_combinations: - return self.mapping_params.param_combinations[0] - else: - # TODO: This isn't quite right - what we want is something like param_template wrapped, - # need a test case with an output filter applied to an empty list, still this is - # an improvement over not allowing mapping of empty lists. - return self.mapping_params.param_template + return self.mapping_params.example_params(self.trans) @property def job_count(self): @@ -510,7 +573,7 @@ class ExecutionTracker: collection_type_description = ( self.trans.app.dataset_collection_manager.collection_type_descriptions.for_collection_type( - input_collection.collection.collection_type + get_collection(input_collection).collection_type ) ) subcollection_mapping_type = None diff --git a/lib/galaxy_test/api/test_tool_execute.py b/lib/galaxy_test/api/test_tool_execute.py index 5a430c49e38..322383b4d55 100644 --- a/lib/galaxy_test/api/test_tool_execute.py +++ b/lib/galaxy_test/api/test_tool_execute.py @@ -256,6 +256,27 @@ def test_map_over_empty_collection(target_history: TargetHistory, required_tool: assert "on collection 1" in name +@requires_tool_id("collection_mapped_over_empty_structured_like") +def test_map_over_empty_with_structured_like_non_mapped_collection_input( + target_history: TargetHistory, required_tool: RequiredTool +): + # Regression guard: an output declared ``structured_like=`` must precreate an implicit output even when the + # mapped-over input is empty (zero jobs). Before the fix, + # example_params fell back to param_template where the non-mapped + # collection's batch wrapper was never substituted, and precreate + # crashed with "Referenced input parameter is not a collection." + empty_hdca = target_history.with_list([]) + shape_hdca = target_history.with_pair(["a", "b"]) + inputs = { + "input1": {"batch": True, "values": [empty_hdca.src_dict]}, + "shape": shape_hdca.src_dict, + } + execute = required_tool.execute().with_inputs(inputs) + execute.assert_has_n_jobs(0) + execute.assert_creates_implicit_collection(0) + + @dataclass class MultiRunInRepeatFixtures: repeat_datasets: list[SrcDict] diff --git a/test/functional/tools/collection_mapped_over_empty_structured_like.xml b/test/functional/tools/collection_mapped_over_empty_structured_like.xml new file mode 100644 index 00000000000..96ef08d70b8 --- /dev/null +++ b/test/functional/tools/collection_mapped_over_empty_structured_like.xml @@ -0,0 +1,13 @@ + + '${list_output.forward}'; + cat '$input1' '${shape.reverse}' '${shape.forward}' > '${list_output.reverse}' + ]]> + + + + + + + + diff --git a/test/functional/tools/sample_tool_conf.xml b/test/functional/tools/sample_tool_conf.xml index afca5944019..1603b0c792c 100644 --- a/test/functional/tools/sample_tool_conf.xml +++ b/test/functional/tools/sample_tool_conf.xml @@ -189,6 +189,7 @@ + diff --git a/test/integration/test_structured_like_unpopulated.py b/test/integration/test_structured_like_unpopulated.py new file mode 100644 index 00000000000..a0e7f71487d --- /dev/null +++ b/test/integration/test_structured_like_unpopulated.py @@ -0,0 +1,93 @@ +"""Integration test for ToolInputsNotReady on structured_like/unpopulated input. + +When a tool output is ``structured_like=""`` and +the user maps the tool over an empty collection, implicit output collection +precreation consults that input to determine output shape. If the referenced +collection is still populating, we should raise ``ToolInputsNotReadyException`` +(HTTP 400, ``TOOL_INPUTS_NOT_READY``) rather than surfacing the cryptic +"Referenced input parameter is not a collection." (Sentry issue GALAXY-MAIN-4KSCZZZ0015NC). + +This test deterministically produces an unpopulated DatasetCollection by +downgrading ``populated_state`` directly in the DB after the collection has +been created via the standard fetch path — the only reliable way to simulate +the race-window state from pure API tests. +""" + +from sqlalchemy import select + +from galaxy.model import ( + DatasetCollection, + HistoryDatasetCollectionAssociation, +) +from galaxy_test.base.populators import ( + DatasetCollectionPopulator, + DatasetPopulator, +) +from galaxy_test.driver import integration_util + + +class TestStructuredLikeUnpopulatedRaisesNotReady(integration_util.IntegrationTestCase): + framework_tool_and_types = True + + dataset_populator: DatasetPopulator + dataset_collection_populator: DatasetCollectionPopulator + require_admin_user = True + + def setUp(self): + super().setUp() + self.dataset_populator = DatasetPopulator(self.galaxy_interactor) + self.dataset_collection_populator = DatasetCollectionPopulator(self.galaxy_interactor) + + @property + def sa_session(self): + return self._app.model.session + + def _mark_collection_unpopulated(self, hdca_id: str) -> None: + hdca_db_id = self._get(f"configuration/decode/{hdca_id}").json()["decoded_id"] + # HDCA.collection_id points at the DatasetCollection row we need. + hdca_model = self.sa_session.scalar( + select(HistoryDatasetCollectionAssociation).where(HistoryDatasetCollectionAssociation.id == hdca_db_id) + ) + assert hdca_model is not None + dc_model = hdca_model.collection + dc_model.populated_state = DatasetCollection.populated_states.NEW + self.sa_session.add(dc_model) + self.sa_session.commit() + + def test_unpopulated_structured_like_target_raises_not_ready(self): + with self.dataset_populator.test_history() as history_id: + empty_hdca = self.dataset_collection_populator.create_list_in_history( + history_id, contents=[], direct_upload=True, wait=True + ).json()["output_collections"][0] + + shape_response = self.dataset_collection_populator.create_pair_in_history( + history_id, contents=["a", "b"], direct_upload=True, wait=True + ).json() + shape_hdca = shape_response["output_collections"][0] + + # Simulate upstream "still populating" — mirrors what + # happens when the referenced collection is an implicit + # collection whose producing jobs haven't finished yet. + self._mark_collection_unpopulated(shape_hdca["id"]) + + inputs = { + "input1": {"batch": True, "values": [{"src": "hdca", "id": empty_hdca["id"]}]}, + "shape": {"src": "hdca", "id": shape_hdca["id"]}, + } + response = self.dataset_populator.run_tool_raw( + tool_id="collection_mapped_over_empty_structured_like", + inputs=inputs, + history_id=history_id, + ) + + # Expect HTTP 400 (ToolInputsNotReadyException) with the + # same message meta.py raises for mapped-over unpopulated + # HDCAs, not the cryptic "Referenced input parameter..." + # that used to reach Sentry. + assert response.status_code == 400, ( + f"Expected 400 for unpopulated input collection, got {response.status_code}: {response.text}" + ) + assert "not populated" in response.text, f"Expected 'not populated' in error body, got: {response.text}" + assert ( + "Referenced input parameter is not a collection" not in response.text + ), "Regression: old cryptic error surfaced instead of ToolInputsNotReady" From ec74829440cf1937122fe96e35e85659d265d0f1 Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Thu, 30 Apr 2026 15:11:28 +0200 Subject: [PATCH 02/21] Format assertion to satisfy black --- test/integration/test_structured_like_unpopulated.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/integration/test_structured_like_unpopulated.py b/test/integration/test_structured_like_unpopulated.py index a0e7f71487d..3f72121574d 100644 --- a/test/integration/test_structured_like_unpopulated.py +++ b/test/integration/test_structured_like_unpopulated.py @@ -84,9 +84,9 @@ class TestStructuredLikeUnpopulatedRaisesNotReady(integration_util.IntegrationTe # same message meta.py raises for mapped-over unpopulated # HDCAs, not the cryptic "Referenced input parameter..." # that used to reach Sentry. - assert response.status_code == 400, ( - f"Expected 400 for unpopulated input collection, got {response.status_code}: {response.text}" - ) + assert ( + response.status_code == 400 + ), f"Expected 400 for unpopulated input collection, got {response.status_code}: {response.text}" assert "not populated" in response.text, f"Expected 'not populated' in error body, got: {response.text}" assert ( "Referenced input parameter is not a collection" not in response.text From ad00cfec5720a8c94339e9706467e4cfb2feb789 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:52:58 +0200 Subject: [PATCH 03/21] Fix error handling for Help Forum integration --- client/src/components/Tool/ToolHelpForum.vue | 9 +++-- lib/galaxy/webapps/galaxy/services/help.py | 38 +++++++++++++++----- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/client/src/components/Tool/ToolHelpForum.vue b/client/src/components/Tool/ToolHelpForum.vue index 912b417a425..49589dec860 100644 --- a/client/src/components/Tool/ToolHelpForum.vue +++ b/client/src/components/Tool/ToolHelpForum.vue @@ -6,10 +6,12 @@ import { computed, onMounted, ref } from "vue"; import { GalaxyApi } from "@/api"; import { galaxyLogo } from "@/components/icons/galaxyIcons"; import { useConfigStore } from "@/stores/configurationStore"; +import { errorMessageAsString } from "@/utils/simple-error"; import { getShortToolId } from "@/utils/tool"; import { createTopicUrl, type HelpForumPost, type HelpForumTopic, useHelpURLs } from "./helpForumUrls"; +import Alert from "@/components/Alert.vue"; import Heading from "@/components/Common/Heading.vue"; import ExternalLink from "@/components/ExternalLink.vue"; @@ -22,6 +24,7 @@ const toolHelpTag = "tool-help"; const topics = ref([]); const posts = ref([]); +const errorMessage = ref(""); const helpAvailable = computed(() => topics.value.length > 0); const root = ref(null); @@ -36,7 +39,7 @@ onMounted(async () => { }, }); if (error) { - console.error("Error fetching help forum data", error); + errorMessage.value = errorMessageAsString(error, "Failed to search the Help Forum."); } topics.value = data?.topics ?? []; @@ -66,12 +69,14 @@ const configStore = useConfigStore();
Help Forum + +

Following questions on the Help Forum may be related to this tool:

-

+

There are no questions on the Help Forum about this tool. diff --git a/lib/galaxy/webapps/galaxy/services/help.py b/lib/galaxy/webapps/galaxy/services/help.py index e75bf087e68..476dda38d4c 100644 --- a/lib/galaxy/webapps/galaxy/services/help.py +++ b/lib/galaxy/webapps/galaxy/services/help.py @@ -1,7 +1,11 @@ import logging from galaxy.config import GalaxyAppConfiguration -from galaxy.exceptions import ServerNotConfiguredForRequest +from galaxy.exceptions import ( + InternalServerError, + MessageException, + ServerNotConfiguredForRequest, +) from galaxy.schema.help import HelpForumSearchResponse from galaxy.security.idencoding import IdEncodingHelper from galaxy.util import requests @@ -34,10 +38,28 @@ class HelpService(ServiceBase): if not self.config.help_forum_api_url: raise ServerNotConfiguredForRequest("Help forum API URL is not configured.") forum_search_url = f"{self.config.help_forum_api_url}/search.json" - response = requests.get( - url=forum_search_url, - params={ - "q": query, - }, - ) - return HelpForumSearchResponse(**response.json()) + try: + response = requests.get( + url=forum_search_url, + params={ + "q": query, + }, + ) + except requests.exceptions.ConnectionError: + raise MessageException( + "Could not connect to the Galaxy Help Forum. The service may be temporarily unavailable." + ) + except requests.exceptions.Timeout: + raise MessageException("The request to the Galaxy Help Forum timed out. Please try again later.") + except requests.exceptions.RequestException as e: + raise InternalServerError(f"An error occurred while requesting the Galaxy Help Forum: {e}") + + if not response.ok: + raise MessageException( + f"The Galaxy Help Forum returned an error (HTTP {response.status_code}). Please try again later." + ) + + try: + return HelpForumSearchResponse(**response.json()) + except ValueError as e: + raise InternalServerError(f"Received an unexpected response format from the Galaxy Help Forum: {e}") From cba5013bc38cf69f63a10a6b388587da3e063ffb Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Thu, 30 Apr 2026 17:04:34 +0200 Subject: [PATCH 04/21] Fix job access check for collection-only outputs `JobManager.get_accessible_job` raised "Job has no output datasets" for non-owners (incl. anonymous users) on jobs that produced only dataset collections, even when the underlying datasets were public. Extend the access check to also consider `output_dataset_collection_instances` and fall back to `HistoryManager.is_accessible` on the job's history. Fixes https://github.com/galaxyproject/galaxy/issues/22602 --- lib/galaxy/managers/jobs.py | 26 +++++++++++---- lib/galaxy/managers/markdown_util.py | 2 +- lib/galaxy_test/api/test_jobs.py | 48 ++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 8 deletions(-) diff --git a/lib/galaxy/managers/jobs.py b/lib/galaxy/managers/jobs.py index 73df74d5d20..feecee1575e 100644 --- a/lib/galaxy/managers/jobs.py +++ b/lib/galaxy/managers/jobs.py @@ -184,9 +184,10 @@ def safe_aliased(model_class: type[T], name: str) -> type[T]: class JobManager: - def __init__(self, app: StructuredApp): + def __init__(self, app: StructuredApp, history_manager: HistoryManager): self.app = app self.dataset_manager = DatasetManager(app) + self.history_manager = history_manager def index_query(self, trans: ProvidesUserContext, payload: JobIndexQueryPayload) -> sqlalchemy.engine.ScalarResult: """The caller is responsible for security checks on the resulting job if @@ -358,15 +359,26 @@ class JobManager: elif trans.galaxy_session: belongs_to_user = job.session_id == trans.galaxy_session.id if not trans.user_is_admin and not belongs_to_user: - # Check access granted via output datasets. - if not job.output_datasets: - raise ItemAccessibilityException("Job has no output datasets.") - for data_assoc in job.output_datasets: - if not self.dataset_manager.is_accessible(data_assoc.dataset.dataset, trans.user): - raise ItemAccessibilityException("You are not allowed to rerun this job.") + if not self._user_can_access_job(job, trans.user): + raise ItemAccessibilityException("You are not allowed to access this job.") trans.sa_session.refresh(job) return job + def _user_can_access_job(self, job: Job, user: Optional[User]) -> bool: + has_outputs = bool(job.output_datasets) or bool(job.output_dataset_collection_instances) + if has_outputs: + datasets_ok = all( + self.dataset_manager.is_accessible(da.dataset.dataset, user) for da in job.output_datasets + ) + collections_ok = all( + self.dataset_manager.is_accessible(hda.dataset, user) + for hdca_assoc in job.output_dataset_collection_instances + for hda in hdca_assoc.dataset_collection_instance.dataset_instances + ) + if datasets_ok and collections_ok: + return True + return job.history is not None and self.history_manager.is_accessible(job.history, user) + def get_job_console_output( self, trans, job, stdout_position=-1, stdout_length=0, stderr_position=-1, stderr_length=0 ): diff --git a/lib/galaxy/managers/markdown_util.py b/lib/galaxy/managers/markdown_util.py index cf5d2763861..b38ef0d3eb4 100644 --- a/lib/galaxy/managers/markdown_util.py +++ b/lib/galaxy/managers/markdown_util.py @@ -141,7 +141,7 @@ class GalaxyInternalMarkdownDirectiveHandler(metaclass=abc.ABCMeta): hda_manager = trans.app.hda_manager history_manager = trans.app.history_manager workflow_manager = trans.app.workflow_manager - job_manager = JobManager(trans.app) + job_manager = JobManager(trans.app, history_manager) collection_manager = trans.app.dataset_collection_manager def _remap(container, line): diff --git a/lib/galaxy_test/api/test_jobs.py b/lib/galaxy_test/api/test_jobs.py index 697e5379bcb..76f9a44baf3 100644 --- a/lib/galaxy_test/api/test_jobs.py +++ b/lib/galaxy_test/api/test_jobs.py @@ -387,6 +387,54 @@ steps: assert show_jobs_response.json()["external_id"] is not None assert show_jobs_response.json()["command_line"] is not None + @skip_without_tool("collection_creates_pair") + @pytest.mark.require_new_history + def test_show_collection_only_job_public(self, history_id): + # Regression test for https://github.com/galaxyproject/galaxy/issues/22602. + job_id, hdca_id = self._run_collection_only_job(history_id) + hdca = self.dataset_populator.get_history_collection_details(history_id, content_id=hdca_id) + for element in hdca["elements"]: + response = self.dataset_populator.make_dataset_public_raw(history_id, element["object"]["id"]) + assert_status_code_is_ok(response) + with self._different_user(anon=True): + show_jobs_response = self._get(f"jobs/{job_id}") + self._assert_status_code_is(show_jobs_response, 200) + assert show_jobs_response.json()["id"] == job_id + + @skip_without_tool("collection_creates_pair") + @pytest.mark.require_new_history + def test_show_collection_only_job_private_denied(self, history_id): + job_id, hdca_id = self._run_collection_only_job(history_id) + hdca = self.dataset_populator.get_history_collection_details(history_id, content_id=hdca_id) + for element in hdca["elements"]: + self.dataset_populator.make_private(history_id, element["object"]["id"]) + with self._different_user(): + show_jobs_response = self._get(f"jobs/{job_id}") + self._assert_status_code_is(show_jobs_response, 403) + + @pytest.mark.require_new_history + def test_show_job_accessible_via_public_history(self, history_id): + self.__history_with_new_dataset(history_id) + jobs_response = self._get("jobs", data={"history_id": history_id}) + job_id = jobs_response.json()[0]["id"] + self.dataset_populator.make_public(history_id) + with self._different_user(): + show_jobs_response = self._get(f"jobs/{job_id}") + self._assert_status_code_is(show_jobs_response, 200) + assert show_jobs_response.json()["id"] == job_id + + def _run_collection_only_job(self, history_id): + input_id = self.dataset_populator.new_dataset(history_id, content="a\nb\nc\nd\n", wait=True)["id"] + run_response = self.dataset_populator.run_tool( + tool_id="collection_creates_pair", + inputs={"input1": {"src": "hda", "id": input_id}}, + history_id=history_id, + ) + job_id = run_response["jobs"][0]["id"] + self.dataset_populator.wait_for_job(job_id, assert_ok=True) + hdca_id = run_response["output_collections"][0]["id"] + return job_id, hdca_id + def _run_detect_errors(self, history_id, inputs): payload = self.dataset_populator.run_tool_payload( tool_id="detect_errors_aggressive", From b0581cab279080d5df07146a54cb6f34dace3dd1 Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Fri, 1 May 2026 18:43:52 +0200 Subject: [PATCH 05/21] Reject malformed dataset ids in data tool parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strings like "hda:" reached SQLAlchemy as the HDA primary key and crashed Postgres with InvalidTextRepresentation (xref #22616). The "src:id" shape is not part of the API surface — those inputs should arrive as {src, id} dicts via src_id_to_item — so reject anything that isn't an int, digit string, or 16-char encoded id with ParameterValueError, which the parameter pipeline maps to a 4xx. --- lib/galaxy/tools/parameters/basic.py | 30 ++++++++++++++++----- test/unit/app/tools/test_data_parameters.py | 12 +++++++++ 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/lib/galaxy/tools/parameters/basic.py b/lib/galaxy/tools/parameters/basic.py index 7ae51e0d472..91d07badae1 100644 --- a/lib/galaxy/tools/parameters/basic.py +++ b/lib/galaxy/tools/parameters/basic.py @@ -2039,6 +2039,25 @@ ItemFromSrcCollection = Union[ ] +def _decode_dataset_id(value, security: "IdEncodingHelper", parameter_name: str) -> int: + """Coerce a value into an integer dataset PK or raise ParameterValueError. + + Accepts int, digit string, or 16-char encoded id. Anything else + (including ``src:...``-prefixed strings, which should arrive as + ``{src, id}`` dicts via :func:`src_id_to_item`) is rejected so + malformed input surfaces as a 4xx instead of a SQL crash. + """ + if isinstance(value, int): + return value + s = str(value) + if s.isdigit(): + return int(s) + if len(s) == 16: + log.warning("Encoded ID where unencoded ID expected.") + return int(security.decode_id(s)) + raise ParameterValueError(f"invalid dataset id {value!r}", parameter_name) + + def src_id_to_item( sa_session: "Session", value: typing.MutableMapping[str, Any], security: "IdEncodingHelper" ) -> ItemFromSrcAny: @@ -2197,12 +2216,8 @@ class DataToolParameter(BaseDataToolParameter): ): rval.append(single_value) else: - if len(str(single_value)) == 16: - # Could never really have an ID this big anyway - postgres doesn't - # support that for integer column types. - log.warning("Encoded ID where unencoded ID expected.") - single_value = trans.security.decode_id(single_value) - rval.append(trans.sa_session.query(HistoryDatasetAssociation).get(single_value)) + pk = _decode_dataset_id(single_value, trans.security, self.name) + rval.append(trans.sa_session.get(HistoryDatasetAssociation, pk)) if len(found_srcs) > 1 and "hdca" in found_srcs: raise ParameterValueError( "if collections are supplied to multiple data input parameter, only collections may be used", @@ -2222,7 +2237,8 @@ class DataToolParameter(BaseDataToolParameter): elif isinstance(value, HistoryDatasetCollectionAssociation) or isinstance(value, DatasetCollectionElement): rval.append(value) else: - rval.append(session.get(HistoryDatasetAssociation, int(value))) + pk = _decode_dataset_id(value, trans.security, self.name) + rval.append(session.get(HistoryDatasetAssociation, pk)) dataset_matcher_factory = get_dataset_matcher_factory(trans) dataset_matcher = dataset_matcher_factory.dataset_matcher(self, other_values) for v in rval: diff --git a/test/unit/app/tools/test_data_parameters.py b/test/unit/app/tools/test_data_parameters.py index f73481135f2..3acf51d77e5 100644 --- a/test/unit/app/tools/test_data_parameters.py +++ b/test/unit/app/tools/test_data_parameters.py @@ -3,8 +3,11 @@ from typing import ( Optional, ) +import pytest + from galaxy import model from galaxy.app_unittest_utils import galaxy_mock +from galaxy.tools.parameters.basic import ParameterValueError from .util import BaseParameterTestCase @@ -33,6 +36,15 @@ class TestDataToolParameter(BaseParameterTestCase): # to just filter it out. assert [hda] == self.param.to_python(f"{hda.id},None", self.app) + def test_from_json_rejects_src_prefixed_string(self): + bogus = "hda:f9cad7b01a472135e2c8f5464c5c5ecb" + with pytest.raises(ParameterValueError, match="invalid dataset id"): + self.param.from_json([bogus], self.trans) + + def test_from_json_rejects_garbage_string(self): + with pytest.raises(ParameterValueError, match="invalid dataset id"): + self.param.from_json("not-an-id", self.trans) + def test_field_filter_on_types(self): hda1 = MockHistoryDatasetAssociation(name="hda1", id=1) hda2 = MockHistoryDatasetAssociation(name="hda2", id=2) From c66919c0a8b95447e634aeeb0feb00c4f2284854 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Mon, 4 May 2026 14:52:12 +0200 Subject: [PATCH 06/21] Improves error handling for Help Forum requests Enhances error reporting and logging for Help Forum API failures, distinguishing between connection errors, timeouts, client errors, and server errors. Provides more actionable error messages for misconfiguration cases and improves diagnostics for administrators. --- lib/galaxy/webapps/galaxy/services/help.py | 38 +++++++++++++++++----- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/lib/galaxy/webapps/galaxy/services/help.py b/lib/galaxy/webapps/galaxy/services/help.py index 476dda38d4c..279fcd3c743 100644 --- a/lib/galaxy/webapps/galaxy/services/help.py +++ b/lib/galaxy/webapps/galaxy/services/help.py @@ -2,9 +2,10 @@ import logging from galaxy.config import GalaxyAppConfiguration from galaxy.exceptions import ( + GatewayTimeoutException, InternalServerError, - MessageException, ServerNotConfiguredForRequest, + UpstreamProxyError, ) from galaxy.schema.help import HelpForumSearchResponse from galaxy.security.idencoding import IdEncodingHelper @@ -45,19 +46,40 @@ class HelpService(ServiceBase): "q": query, }, ) - except requests.exceptions.ConnectionError: - raise MessageException( + except requests.exceptions.ConnectionError as e: + log.error("Could not connect to the Galaxy Help Forum at %s: %s", forum_search_url, e) + raise UpstreamProxyError( "Could not connect to the Galaxy Help Forum. The service may be temporarily unavailable." ) - except requests.exceptions.Timeout: - raise MessageException("The request to the Galaxy Help Forum timed out. Please try again later.") + except requests.exceptions.Timeout as e: + log.error("Request to the Galaxy Help Forum at %s timed out: %s", forum_search_url, e) + raise GatewayTimeoutException("The request to the Galaxy Help Forum timed out. Please try again later.") except requests.exceptions.RequestException as e: + log.error("Unexpected error requesting the Galaxy Help Forum at %s: %s", forum_search_url, e) raise InternalServerError(f"An error occurred while requesting the Galaxy Help Forum: {e}") if not response.ok: - raise MessageException( - f"The Galaxy Help Forum returned an error (HTTP {response.status_code}). Please try again later." - ) + if 400 <= response.status_code < 500: + log.error( + "The Galaxy Help Forum returned a client error (HTTP %d) from %s. " + "This likely indicates a misconfigured URL or API key.", + response.status_code, + forum_search_url, + ) + raise InternalServerError( + f"The Galaxy Help Forum returned an error (HTTP {response.status_code}). " + "This may indicate a misconfigured URL or API key that requires admin intervention." + ) + else: + log.error( + "The Galaxy Help Forum returned a server error (HTTP %d) from %s", + response.status_code, + forum_search_url, + ) + raise UpstreamProxyError( + f"The Galaxy Help Forum returned an error (HTTP {response.status_code}). " + "The service may be temporarily unavailable. Please try again later." + ) try: return HelpForumSearchResponse(**response.json()) From 483c91c85e5eb37099dfdb108ef5016164795b4a Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Fri, 8 May 2026 20:31:18 +0200 Subject: [PATCH 07/21] Filter null values from rerun-hydrated data inputs Re-running a workflow that has an optional `data_input` / `data_collection_input` step left empty on the original run failed with a pydantic `DataOrCollectionRequest` ValidationError ("'values' not permitted" against `DataRequestHdca`/`FileRequestUri`/ `DataRequestCollectionUri`). `WorkflowInvocationRequestModel.inputs` returns `null` for unset optional inputs; WorkflowRunFormSimple.vue wrapped that as `{values: [null]}` regardless, and the resulting `null` entry crashed `FormData.vue`'s `onMounted` hook on `"src" in null`. Because the mounted hook never completed, the bad wrapper survived in formData and was sent to the server. Filter `null`/`undefined` entries out of the rerun-hydrated array, and skip the assignment entirely when no real values remain so the form falls back to its default for the optional input. Sibling fix to #22601. --- .../Workflow/Run/WorkflowRunFormSimple.vue | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/client/src/components/Workflow/Run/WorkflowRunFormSimple.vue b/client/src/components/Workflow/Run/WorkflowRunFormSimple.vue index eed77fbdb1c..281b3ba6aad 100644 --- a/client/src/components/Workflow/Run/WorkflowRunFormSimple.vue +++ b/client/src/components/Workflow/Run/WorkflowRunFormSimple.vue @@ -176,9 +176,18 @@ const formInputs = computed(() => { if (stepType === "data_input" || stepType === "data_collection_input") { // Note: This is different from workflow landings because `WorkflowInvocationRequestModel` // does not provide an object with `values` property. - stepAsInput.value = { - values: !Array.isArray(value) ? [value] : value, - }; + // Optional data inputs left empty on the original run come back as `null` (or + // arrays containing `null`). Filter those out so we don't poison FormData with + // `{values: [null]}`, which crashes its `onMounted` hook on `"src" in null` and + // leaves the bad wrapper in formData to be sent to the server. + const valuesArray = (Array.isArray(value) ? value : [value]).filter( + (v) => v !== null && v !== undefined, + ); + if (valuesArray.length > 0) { + stepAsInput.value = { + values: valuesArray, + }; + } } else { stepAsInput.value = value; } From 78d93f46a88de1279ec150d820eee492079c6997 Mon Sep 17 00:00:00 2001 From: guerler Date: Sun, 10 May 2026 14:57:12 +0300 Subject: [PATCH 08/21] Update vintent --- client/visualizations.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/visualizations.yml b/client/visualizations.yml index f291a57e929..bf90341125e 100644 --- a/client/visualizations.yml +++ b/client/visualizations.yml @@ -122,7 +122,7 @@ venn: version: 0.0.8 vintent: package: "@galaxyproject/vintent" - version: 0.0.0 + version: 0.0.4 vitessce: package: "@galaxyproject/vitessce" version: 0.0.5 From 472989caf48666082f49d88f966574016fc03068 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Sun, 3 May 2026 23:51:58 +0200 Subject: [PATCH 09/21] fix webdav file download This commit can be dropped after 26.1 release --- lib/galaxy/files/sources/webdav.py | 11 +++++++++++ test/unit/files/test_webdav.py | 17 +++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/lib/galaxy/files/sources/webdav.py b/lib/galaxy/files/sources/webdav.py index e65193087a4..7101b371a48 100644 --- a/lib/galaxy/files/sources/webdav.py +++ b/lib/galaxy/files/sources/webdav.py @@ -6,6 +6,7 @@ except ImportError: import tempfile from typing import ( Annotated, + Any, Optional, Union, ) @@ -65,6 +66,16 @@ class WebDavFilesSource(PyFilesystem2FilesSource[WebDavFileSourceTemplateConfigu template_config_class = WebDavFileSourceTemplateConfiguration resolved_config_class = WebDavFileSourceConfiguration + def _serialize_config(self, config: WebDavFileSourceConfiguration) -> dict[str, Any]: + result = super()._serialize_config(config) + # 'url' is in COMMON_FILE_SOURCE_PROP_NAMES so it is excluded by the base class + # _serialize_config. For WebDAV, 'url' is the server endpoint (not a display URL), + # so it must be preserved in the serialized form used to reconstruct the plugin on + # job runners. Without it, url defaults to None and WebDAVFS raises AttributeError. + if config.url is not None: + result["url"] = config.url + return result + def _open_fs(self, context: FilesSourceRuntimeContext[WebDavFileSourceConfiguration]): if WebDAVFS is None: raise self.required_package_exception diff --git a/test/unit/files/test_webdav.py b/test/unit/files/test_webdav.py index c66df98435c..65418bd83ff 100644 --- a/test/unit/files/test_webdav.py +++ b/test/unit/files/test_webdav.py @@ -120,3 +120,20 @@ def test_serialization_user(): file_sources = serialize_and_recover(file_sources_o, user_context=user_context) res = list_root(file_sources, "gxfiles://test1", recursive=True, user_context=None) assert find_file_a(res) + + +@skip_if_no_webdav +def test_url_preserved_in_serialization(): + # Regression test: 'url' is in COMMON_FILE_SOURCE_PROP_NAMES and was excluded from + # _serialize_config, causing WebDAVFS to be initialized with url=None, which led to + # AttributeError: 'NoneType' object has no attribute 'rstrip' when fetching files. + file_sources = configured_file_sources(FILE_SOURCES_CONF) + fs = file_source_as_webdav(file_sources._file_sources[0]) + + serialized = fs.to_dict(for_serialization=True) + assert "url" in serialized, "WebDAV url must be preserved in serialized form for job runner reconstruction" + assert serialized["url"] == "http://127.0.0.1:7083" + + recovered = serialize_and_recover(file_sources) + recovered_fs = file_source_as_webdav(recovered._file_sources[0]) + assert recovered_fs._get_runtime_context().config.url == "http://127.0.0.1:7083" From f9f1f7a559a112086237503f42986d14591c75e6 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Thu, 14 May 2026 15:40:40 +0200 Subject: [PATCH 10/21] Restricts record browsing to user with auth token Ensures authenticated users can only view their own records when they provide the authorization token to simplify access. --- lib/galaxy/files/sources/invenio.py | 4 +++- lib/galaxy/files/templates/examples/production_zenodo.yml | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/galaxy/files/sources/invenio.py b/lib/galaxy/files/sources/invenio.py index e78e182e89d..a83d04abb60 100644 --- a/lib/galaxy/files/sources/invenio.py +++ b/lib/galaxy/files/sources/invenio.py @@ -267,10 +267,12 @@ class InvenioRepositoryInteractor(RDMRepositoryInteractor): """Gets the records in the repository and returns the total count of records.""" params: dict[str, Any] = {} request_url = self.records_url + if self.plugin.get_authorization_token(context) or write_intent: + # Authenticated users should browse only their own records. + request_url = self.user_records_url if write_intent: # Only draft records owned by the user can be written to. params["is_published"] = "false" - request_url = self.user_records_url size, page = self._to_size_page(limit, offset) params["size"] = size params["page"] = page diff --git a/lib/galaxy/files/templates/examples/production_zenodo.yml b/lib/galaxy/files/templates/examples/production_zenodo.yml index 0623ff27ff0..46a6395913b 100644 --- a/lib/galaxy/files/templates/examples/production_zenodo.yml +++ b/lib/galaxy/files/templates/examples/production_zenodo.yml @@ -32,7 +32,9 @@ The personal access token to use to connect to Zenodo. Go to your Account Settings and then go to Applications here https://zenodo.org/account/settings/applications/. You can generate a new `Personal Access Token` if you don't have one yet. This will allow Galaxy to display your draft records and upload files to them. If you enabled the option to export data - from Galaxy to Zenodo, make sure to enable the `deposit:write` scope when creating the token. + from Galaxy to Zenodo, make sure to enable the `deposit:write` scope when creating the token. + + **Note**: If you provide a token, you will be able to browse **only your own records**, if you don't provide a token, you will be able to browse all public records in Zenodo. configuration: type: zenodo From 39ee03bd770f0e58af882c674765a3674339ec78 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Thu, 14 May 2026 15:49:21 +0200 Subject: [PATCH 11/21] Fixes draft record browsing for own records Ensures users can browse files in their own draft records by checking both write permissions and draft status. Addresses issues with accessing draft content when not explicitly in writeable mode. --- lib/galaxy/files/sources/invenio.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/galaxy/files/sources/invenio.py b/lib/galaxy/files/sources/invenio.py index a83d04abb60..09999de76c6 100644 --- a/lib/galaxy/files/sources/invenio.py +++ b/lib/galaxy/files/sources/invenio.py @@ -297,7 +297,7 @@ class InvenioRepositoryInteractor(RDMRepositoryInteractor): writeable: bool, query: Optional[str] = None, ) -> list[RemoteFile]: - conditionally_draft = "/draft" if writeable else "" + conditionally_draft = "/draft" if writeable or self._is_draft_record(container_id, context) else "" request_url = f"{self.records_url}/{container_id}{conditionally_draft}/files" response_data = self._get_response(context, request_url) return self._get_record_files_from_response(container_id, response_data) From a200cd14b488a6d2bd1cf14b94ce647d40f1eb09 Mon Sep 17 00:00:00 2001 From: Ahmed Awan Date: Thu, 14 May 2026 15:52:39 -0500 Subject: [PATCH 12/21] [26.0] Ensure workflow editor always inserts latest tool version This is done by passing `tool_version: latest` to `POST api/workflows/build_module`, which is then on the backend resolved by removing the version from the tool's GUID, unless we explicitly pass a `tool_uuid` (for UDTs for e.g.). Fixes https://github.com/galaxyproject/galaxy/issues/22646 --- client/src/components/Workflow/Editor/Index.vue | 16 +++++++++++++++- lib/galaxy/workflow/modules.py | 8 ++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/client/src/components/Workflow/Editor/Index.vue b/client/src/components/Workflow/Editor/Index.vue index b854e91200f..b1c6482d619 100644 --- a/client/src/components/Workflow/Editor/Index.vue +++ b/client/src/components/Workflow/Editor/Index.vue @@ -1145,7 +1145,17 @@ export default { const stepData = action.getNewStepData(); const response = await getModule( - { name, type, content_id: contentId, tool_state: state, tool_uuid: toolUuid }, + { + name, + type, + content_id: contentId, + tool_state: state, + tool_uuid: toolUuid, + // Request the latest version, mirroring what `routeToTool` does with `&version=latest`. + // Without this, toolshed tools whose GUID includes the version would resolve to + // that specific (possibly old) version rather than the latest. + tool_version: type === "tool" && !toolUuid ? "latest" : undefined, + }, stepData.id, this.stateStore.setLoadingState, ); @@ -1157,6 +1167,10 @@ export default { inputs: response.inputs, outputs: response.outputs, config_form: response.config_form, + // Use the resolved content_id and tool_version from the response which + // references the latest tool version, rather than what comes from the GUID + content_id: response.content_id || stepData.content_id, + tool_version: response.tool_version || stepData.tool_version, }; this.stepStore.updateStep(updatedStep); diff --git a/lib/galaxy/workflow/modules.py b/lib/galaxy/workflow/modules.py index 997b3957da3..e75b012e5b9 100644 --- a/lib/galaxy/workflow/modules.py +++ b/lib/galaxy/workflow/modules.py @@ -127,6 +127,7 @@ from galaxy.util.json import safe_loads from galaxy.util.rules_dsl import RuleSet from galaxy.util.template import fill_template from galaxy.util.tool_shed.common_util import get_tool_shed_url_from_tool_shed_registry +from galaxy.util.tool_version import remove_version_from_guid from galaxy.workflow.workflow_parameter_input_definitions import ( get_default_parameter, INPUT_PARAMETER_TYPES, @@ -1993,6 +1994,13 @@ class ToolModule(WorkflowModule): if tool_version: tool_version = str(tool_version) tool_uuid = d.get("tool_uuid", None) + if tool_version == "latest": + # Resolve to the actual latest installed version via lineage rather than matching the exact GUID. + # This mirrors what &version=latest does in the tool form. + tool_version = None + if tool_id: + tool_id = remove_version_from_guid(tool_id) or tool_id + kwds = dict(kwds, exact_tools=False) if tool_id is None and tool_uuid is None: tool_representation = d.get("tool_representation") if tool_representation: From db8544fd96b313772d4fc0905c54aff1d462be97 Mon Sep 17 00:00:00 2001 From: Ahmed Awan Date: Thu, 14 May 2026 16:05:09 -0500 Subject: [PATCH 13/21] add unit tests for tool_version="latest" resolution in ToolModule.from_dict Co-Authored-By: Claude --- test/unit/workflows/test_modules.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/test/unit/workflows/test_modules.py b/test/unit/workflows/test_modules.py index f60e6b85ad2..6ef6f84911f 100644 --- a/test/unit/workflows/test_modules.py +++ b/test/unit/workflows/test_modules.py @@ -130,6 +130,33 @@ def test_cannot_create_tool_modules_for_missing_tools(): assert not module.tool +def test_tool_version_latest_resolves_toolshed_guid(): + # Toolshed GUIDs embed the version as the last segment. When tool_version="latest" + # is requested (as the WF editor does on insert), from_dict should strip the version + # from the GUID and resolve to the latest installed version via the versionless key. + trans = MockTrans() + old_guid = "toolshed.g2.bx.psu.edu/repos/devteam/fastqc/fastqc/0.68+galaxy1" + versionless_guid = "toolshed.g2.bx.psu.edu/repos/devteam/fastqc/fastqc" + latest_tool = __mock_tool( + id="toolshed.g2.bx.psu.edu/repos/devteam/fastqc/fastqc/0.74+galaxy1", version="0.74+galaxy1" + ) + trans.app.toolbox.tools[versionless_guid] = latest_tool + module = modules.module_factory.from_dict(trans, {"type": "tool", "content_id": old_guid, "tool_version": "latest"}) + assert module.tool is not None + assert module.tool.version == "0.74+galaxy1" + + +def test_tool_version_latest_resolves_builtin_tool(): + # Built-in tool IDs have no version segment; remove_version_from_guid returns None + # so the ID is unchanged. tool_version="latest" should still resolve correctly. + trans = MockTrans() + latest_tool = __mock_tool(id="cat1", version="2.0") + trans.app.toolbox.tools["cat1"] = latest_tool + module = modules.module_factory.from_dict(trans, {"type": "tool", "content_id": "cat1", "tool_version": "latest"}) + assert module.tool is not None + assert module.tool.version == "2.0" + + def test_updated_tool_version(): trans = MockTrans() mock_tool = __mock_tool(id="cat1", version="0.9") From 394aac4fa933227c9aa196a885cce4fc05e14c57 Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Fri, 8 May 2026 20:31:18 +0200 Subject: [PATCH 14/21] Add selenium regression test for unset optional data input rerun Selenium regression for the WorkflowRunFormSimple data-input rerun hydration crash fixed in the prior commit. Runs `WORKFLOW_OPTIONAL_TRUE_INPUT_DATA` (a single optional `data_input`) without selecting a dataset, then reruns without changes and asserts a new ok output appears. Pre-fix, the rerun submission raised a "Workflow submission failed: ... 'values' not permitted ..." toast because the rerun-hydrated wrapper contained `[null]` (left over from the optional input the user didn't fill on the original run) and `FormData.vue`'s mounted hook crashed on `"src" in null` before it could normalize the value. Originally reported on a "1 or 2 haplotypes" workflow where users left the second-haplotype input empty. --- .../selenium/test_workflow_rerun.py | 45 ++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/lib/galaxy_test/selenium/test_workflow_rerun.py b/lib/galaxy_test/selenium/test_workflow_rerun.py index 1b4c0dee827..4e44b842f22 100644 --- a/lib/galaxy_test/selenium/test_workflow_rerun.py +++ b/lib/galaxy_test/selenium/test_workflow_rerun.py @@ -1,4 +1,7 @@ -from galaxy_test.base.workflow_fixtures import WORKFLOW_SIMPLE_CAT_TWICE +from galaxy_test.base.workflow_fixtures import ( + WORKFLOW_OPTIONAL_TRUE_INPUT_DATA, + WORKFLOW_SIMPLE_CAT_TWICE, +) from .framework import ( managed_history, retry_assertion_during_transitions, @@ -136,6 +139,46 @@ class TestWorkflowRun(SeleniumTestCase, UsesHistoryItemAssertions, RunsWorkflows self._assert_input_table_has_parameter("bool_param", "true") self.screenshot("workflow_rerun_boolean_inputs_tab") + @selenium_test + @managed_history + def test_workflow_rerun_with_unset_optional_data_input(self): + """Regression test for workflow rerun crashing when an optional data_input was left empty. + + When a workflow has an optional ``data_input`` step that the user did + not provide on the original run, ``WorkflowInvocationRequestModel.inputs`` + returns ``null`` for that step. WorkflowRunFormSimple.vue used to wrap + the value as ``{values: [null]}`` regardless, which crashed the + ``FormData`` mounted hook on ``"src" in null``. Because the mounted + hook never completed, the bad wrapper survived in formData and was + sent to the server, which rejected it with a ``DataOrCollectionRequest`` + union ValidationError ("Extra inputs are not permitted in 'values'"). + Reported on a "1 or 2 haplotypes" workflow where the second haplotype + was left empty. + """ + invocations = self.components.invocations + + # Upload one HDA so the history isn't empty (the workflow form needs a + # current history); the optional input is intentionally left unselected. + self.perform_upload(self.get_filename("1.fasta")) + self.wait_for_history() + self.workflow_run_open_workflow(WORKFLOW_OPTIONAL_TRUE_INPUT_DATA) + self.workflow_run_submit() + self.sleep_for(self.wait_types.UX_TRANSITION) + self.workflow_run_wait_for_ok(hid=2, expand=True) + + # Submitting lands us on the new invocation page; click rerun directly. + invocations.state_details.wait_for_visible() + invocations.workflow_rerun_button.wait_for_and_click() + self.sleep_for(self.wait_types.UX_TRANSITION) + + # Submit the rerun without touching anything. Pre-fix this raises a + # "Workflow submission failed: ... 'values' not permitted ..." toast + # because formData carries the poisoned `{values: [null]}` wrapper. + self.workflow_run_submit() + self.sleep_for(self.wait_types.UX_TRANSITION) + self.workflow_run_wait_for_ok(hid=3, expand=True) + self.screenshot("workflow_rerun_unset_optional_data_input_submitted") + @retry_assertion_during_transitions def _assert_input_table_has_parameter(self, label: str, value: str): table = self.wait_for_selector('[data-description="input table"]') From 30e2b9e04ba314861a689a61a79cfa5f129b33d4 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Mon, 18 May 2026 21:23:32 +0200 Subject: [PATCH 15/21] add TEI XML datatype --- .../config/sample/datatypes_conf.xml.sample | 1 + lib/galaxy/datatypes/xml.py | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/lib/galaxy/config/sample/datatypes_conf.xml.sample b/lib/galaxy/config/sample/datatypes_conf.xml.sample index 6c57e33e8e1..35ca03567e2 100644 --- a/lib/galaxy/config/sample/datatypes_conf.xml.sample +++ b/lib/galaxy/config/sample/datatypes_conf.xml.sample @@ -573,6 +573,7 @@ + diff --git a/lib/galaxy/datatypes/xml.py b/lib/galaxy/datatypes/xml.py index 24117b1227e..20f5037fc66 100644 --- a/lib/galaxy/datatypes/xml.py +++ b/lib/galaxy/datatypes/xml.py @@ -24,6 +24,10 @@ log = logging.getLogger(__name__) OWL_MARKER = re.compile(r"\]*>\s*)?(?:\s*)*<([A-Za-z_][\w.-]*:)?TEI(?:\s|>|/)", + re.DOTALL, +) @dataproviders.decorators.has_dataproviders @@ -113,6 +117,28 @@ class CisML(GenericXml): dataset.blurb = "file purged from disk" +class Tei(GenericXml): + """Text Encoding Initiative XML data.""" + + edam_format = "format_2332" + file_ext = "tei" + + def set_peek(self, dataset: DatasetProtocol, **kwd) -> None: + """Set the peek and blurb text""" + if not dataset.dataset.purged: + dataset.peek = data.get_file_peek(dataset.get_file_name()) + dataset.blurb = "TEI XML data" + else: + dataset.peek = "file does not exist" + dataset.blurb = "file purged from disk" + + def sniff_prefix(self, file_prefix: FilePrefix) -> bool: + """ + Determines whether the file is TEI XML. + """ + return bool(file_prefix.search(TEI_MARKER)) + + class Dzi(GenericXml): """ Deep zoom image format, see From a76774f93f7a90034a22a7947f4a8e53b789835c Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Mon, 18 May 2026 21:25:36 +0200 Subject: [PATCH 16/21] remove copy&paste --- lib/galaxy/datatypes/xml.py | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/galaxy/datatypes/xml.py b/lib/galaxy/datatypes/xml.py index 20f5037fc66..e2bedca767c 100644 --- a/lib/galaxy/datatypes/xml.py +++ b/lib/galaxy/datatypes/xml.py @@ -120,7 +120,6 @@ class CisML(GenericXml): class Tei(GenericXml): """Text Encoding Initiative XML data.""" - edam_format = "format_2332" file_ext = "tei" def set_peek(self, dataset: DatasetProtocol, **kwd) -> None: From 26b51d366213d3f19f167d5817b3bd7de076bc89 Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Tue, 12 May 2026 18:23:10 +0200 Subject: [PATCH 17/21] Fix data source tool redirect back to Galaxy SPA The mako-template removal in 76341658dcc deleted the JavaScript `top.location.href = '/'` redirect that returned the user to Galaxy after an external data source import in a new tab. The replacement `show_ok_message` rendered a static page, leaving the new tab stranded on a "job queued" message instead of navigating back to the SPA. Restore the redirect with a 302 to `/?notification=tool-submitted` from both `ToolRunner.index` and `ASync.index`, and have `Home.vue` surface a `useToast` notification on landing (then strip the query param so a reload doesn't re-fire it). Fixes #22671 --- client/src/entry/analysis/modules/Home.vue | 15 +++++++++++++++ lib/galaxy/webapps/galaxy/controllers/async.py | 7 ++++--- .../webapps/galaxy/controllers/tool_runner.py | 9 +++------ lib/galaxy_test/api/test_authenticate.py | 9 +++++---- .../selenium/test_data_source_tools.py | 18 ++++++++++++++++++ 5 files changed, 45 insertions(+), 13 deletions(-) diff --git a/client/src/entry/analysis/modules/Home.vue b/client/src/entry/analysis/modules/Home.vue index ca2bfff931f..5a86089714c 100644 --- a/client/src/entry/analysis/modules/Home.vue +++ b/client/src/entry/analysis/modules/Home.vue @@ -10,6 +10,8 @@