Skip to content

Commit 702ae62

Browse files
committed
Merge branch 'release_26.0' into dev
2 parents 4868eb3 + 9623f4c commit 702ae62

7 files changed

Lines changed: 90 additions & 42 deletions

File tree

client/src/components/History/HistoryList.vue

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ const limit = ref(24);
9393
const offset = ref(0);
9494
const loading = ref(true);
9595
const overlay = ref(false);
96+
let loadGeneration = 0;
9697
const filterText = ref("");
9798
const totalHistories = ref(0);
9899
const showAdvanced = ref(false);
@@ -220,6 +221,8 @@ function onToggleDeleted() {
220221
* @param {boolean} silent - Whether to skip loading indicators
221222
*/
222223
async function load(overlayLoading: boolean = false, silent: boolean = false) {
224+
const thisGeneration = ++loadGeneration;
225+
223226
if (!silent) {
224227
if (overlayLoading) {
225228
overlay.value = true;
@@ -243,32 +246,37 @@ async function load(overlayLoading: boolean = false, silent: boolean = false) {
243246
};
244247
245248
try {
246-
if (myView.value) {
247-
const { data, total } = await getMyHistories(options);
249+
let data;
250+
let total;
248251
249-
historiesLoaded.value = data;
250-
totalHistories.value = total;
252+
if (myView.value) {
253+
({ data, total } = await getMyHistories(options));
251254
} else if (sharedView.value) {
252-
const { data, total } = await getSharedHistories(options);
253-
254-
historiesLoaded.value = data;
255-
totalHistories.value = total;
255+
({ data, total } = await getSharedHistories(options));
256256
} else if (publishedView.value) {
257-
const { data, total } = await getPublishedHistories(options);
258-
259-
historiesLoaded.value = data;
260-
totalHistories.value = total;
257+
({ data, total } = await getPublishedHistories(options));
261258
} else if (archivedView.value) {
262-
const { data, total } = await getArchivedHistories(options);
259+
({ data, total } = await getArchivedHistories(options));
260+
}
263261
262+
if (thisGeneration !== loadGeneration) {
263+
return;
264+
}
265+
266+
if (data !== undefined) {
264267
historiesLoaded.value = data;
265-
totalHistories.value = total;
268+
totalHistories.value = total!;
266269
}
267270
} catch (error) {
271+
if (thisGeneration !== loadGeneration) {
272+
return;
273+
}
268274
Toast.error(`Failed to load histories: ${errorMessageAsString(error)}`);
269275
} finally {
270-
loading.value = false;
271-
overlay.value = false;
276+
if (thisGeneration === loadGeneration) {
277+
loading.value = false;
278+
overlay.value = false;
279+
}
272280
}
273281
}
274282

lib/galaxy/agents/error_analysis.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@
44

55
import logging
66
import re
7+
from functools import partial
78
from pathlib import Path
89
from typing import (
910
Any,
1011
Literal,
1112
Optional,
1213
)
1314

15+
import anyio
1416
from pydantic import BaseModel
1517
from pydantic_ai import Agent
1618

@@ -94,7 +96,9 @@ async def get_job_details(self, job_id: int) -> dict[str, Any]:
9496
if not self.deps.job_manager:
9597
return {"error": "Job manager not available"}
9698

97-
job = self.deps.job_manager.get_accessible_job(self.deps.trans, job_id)
99+
job = await anyio.to_thread.run_sync(
100+
partial(self.deps.job_manager.get_accessible_job, self.deps.trans, job_id)
101+
)
98102
if not job:
99103
return {"error": f"Job {job_id} not found or not accessible"}
100104

lib/galaxy/tools/parameters/basic.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -512,12 +512,12 @@ def from_json(self, value, trans, other_values=None):
512512
def to_python(self, value, app):
513513
try:
514514
return int(value)
515-
except (TypeError, ValueError) as err:
515+
except (TypeError, ValueError):
516516
if contains_workflow_parameter(value):
517517
return value
518518
if not value and self.optional:
519519
return None
520-
raise err
520+
raise ParameterValueError("an integer is required", self.name, value)
521521

522522
def get_initial_value(self, trans, other_values):
523523
if self.value is not None and self.value != "":
@@ -585,12 +585,12 @@ def from_json(self, value, trans, other_values=None):
585585
def to_python(self, value, app):
586586
try:
587587
return float(value)
588-
except (TypeError, ValueError) as err:
588+
except (TypeError, ValueError):
589589
if contains_workflow_parameter(value):
590590
return value
591591
if not value and self.optional:
592592
return None
593-
raise err
593+
raise ParameterValueError("a float is required", self.name, value)
594594

595595
def get_initial_value(self, trans, other_values):
596596
if self.value is None:

lib/galaxy/webapps/galaxy/api/agents.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22

33
import logging
44
import time
5+
from functools import partial
56
from typing import (
67
Any,
78
Optional,
89
)
910

11+
import anyio
1012
from fastapi import Body
1113

1214
from galaxy.exceptions import ConfigurationError
@@ -154,16 +156,20 @@ async def analyze_error(
154156
# Save chat exchange for feedback tracking if requested or if job_id provided
155157
if bool(save_exchange) or job_id:
156158
if job_id:
157-
job = self.job_manager.get_accessible_job(trans, job_id)
159+
job = await anyio.to_thread.run_sync(partial(self.job_manager.get_accessible_job, trans, job_id))
158160
if job:
159-
existing = self.chat_manager.get(trans, job.id)
161+
existing = await anyio.to_thread.run_sync(partial(self.chat_manager.get, trans, job.id))
160162
if not existing:
161-
exchange = self.chat_manager.create(trans, job.id, response.content)
163+
exchange = await anyio.to_thread.run_sync(
164+
partial(self.chat_manager.create, trans, job.id, response.content)
165+
)
162166
response.metadata["exchange_id"] = exchange.id
163167
elif trans.user:
164168
# Create general chat exchange for non-job error analysis
165169
result = {"response": response.content, "agent_response": response.model_dump()}
166-
exchange = self.chat_manager.create_general_chat(trans, query, result, "error_analysis")
170+
exchange = await anyio.to_thread.run_sync(
171+
partial(self.chat_manager.create_general_chat, trans, query, result, "error_analysis")
172+
)
167173
response.metadata["exchange_id"] = exchange.id
168174

169175
return response
@@ -200,7 +206,9 @@ async def create_custom_tool(
200206
# Save chat exchange for feedback tracking if requested
201207
if bool(save_exchange) and trans.user:
202208
result = {"response": response.content, "agent_response": response.model_dump()}
203-
exchange = self.chat_manager.create_general_chat(trans, query, result, "custom_tool")
209+
exchange = await anyio.to_thread.run_sync(
210+
partial(self.chat_manager.create_general_chat, trans, query, result, "custom_tool")
211+
)
204212
response.metadata["exchange_id"] = exchange.id
205213

206214
return response

lib/galaxy/webapps/galaxy/api/chat.py

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@
55
import json
66
import logging
77
import time
8+
from functools import partial
89
from typing import (
910
Annotated,
1011
Any,
1112
Optional,
1213
Union,
1314
)
1415

16+
import anyio
1517
from fastapi import (
1618
Body,
1719
Path,
@@ -59,8 +61,10 @@
5961
# Keep OpenAI as a fallback option
6062
try:
6163
import openai
64+
from openai import AsyncOpenAI
6265
except ImportError:
6366
openai = None # type: ignore[assignment]
67+
AsyncOpenAI = None # type: ignore[assignment,misc]
6468

6569
log = logging.getLogger(__name__)
6670

@@ -152,9 +156,9 @@ async def query(
152156
job = None
153157
if job_id:
154158
# Job-based chat - check for existing responses (unless regenerate requested)
155-
job = self.job_manager.get_accessible_job(trans, job_id)
159+
job = await anyio.to_thread.run_sync(partial(self.job_manager.get_accessible_job, trans, job_id))
156160
if job and not regenerate:
157-
existing_response = self.chat_manager.get(trans, job.id)
161+
existing_response = await anyio.to_thread.run_sync(partial(self.chat_manager.get, trans, job.id))
158162
if existing_response and existing_response.messages[0]:
159163
return ChatResponse(
160164
response=existing_response.messages[0].message,
@@ -176,7 +180,9 @@ async def query(
176180

177181
# If we have an exchange_id, ALWAYS load conversation history from database (source of truth)
178182
if exchange_id:
179-
db_history = self.chat_manager.get_chat_history(trans, exchange_id, format_for_pydantic_ai=False)
183+
db_history = await anyio.to_thread.run_sync(
184+
partial(self.chat_manager.get_chat_history, trans, exchange_id, format_for_pydantic_ai=False)
185+
)
180186
if db_history:
181187
full_context["conversation_history"] = db_history
182188
else:
@@ -197,13 +203,15 @@ async def query(
197203
self._ensure_ai_configured()
198204
# For legacy, use context_type from query_context if it exists
199205
context_type = query_context.get("context_type") if isinstance(query_context, dict) else None
200-
answer = self._get_ai_response(query_text, trans, context_type)
206+
answer = await self._get_ai_response(query_text, trans, context_type)
201207
result["response"] = answer
202208

203209
# Save chat exchange to database
204210
if job:
205211
# Job-based chat
206-
exchange = self.chat_manager.create(trans, job.id, str(result["response"]))
212+
exchange = await anyio.to_thread.run_sync(
213+
partial(self.chat_manager.create, trans, job.id, str(result["response"]))
214+
)
207215
result["exchange_id"] = exchange.id
208216
elif trans.user:
209217
# Use the exchange_id we already extracted at the beginning
@@ -217,7 +225,9 @@ async def query(
217225
"agent_response": agent_resp.model_dump() if agent_resp else None,
218226
}
219227
message_content = json.dumps(conversation_data)
220-
self.chat_manager.add_message(trans, exchange_id, message_content)
228+
await anyio.to_thread.run_sync(
229+
partial(self.chat_manager.add_message, trans, exchange_id, message_content)
230+
)
221231
result["exchange_id"] = exchange_id
222232
else:
223233
# Create new exchange for first message
@@ -227,7 +237,9 @@ async def query(
227237
"response": result.get("response", ""),
228238
"agent_response": agent_resp.model_dump() if agent_resp else None,
229239
}
230-
exchange = self.chat_manager.create_general_chat(trans, query_text, storable_result, agent_type)
240+
exchange = await anyio.to_thread.run_sync(
241+
partial(self.chat_manager.create_general_chat, trans, query_text, storable_result, agent_type)
242+
)
231243
result["exchange_id"] = exchange.id
232244

233245
result["processing_time"] = time.time() - start_time
@@ -421,7 +433,7 @@ def _ensure_ai_configured(self):
421433
if self.config.ai_api_key is None:
422434
raise ConfigurationError("AI API key is not configured for this instance.")
423435

424-
def _get_ai_response(self, query: str, trans: ProvidesUserContext, context_type: Optional[str] = None) -> str:
436+
async def _get_ai_response(self, query: str, trans: ProvidesUserContext, context_type: Optional[str] = None) -> str:
425437
"""Get response from AI using pydantic-ai Agent"""
426438
system_prompt = self._get_system_prompt()
427439
username = trans.user.username if trans.user else "Anonymous User"
@@ -432,8 +444,8 @@ def _get_ai_response(self, query: str, trans: ProvidesUserContext, context_type:
432444
full_system_prompt = f"{system_prompt}\n\nYou will address the user as {username}"
433445
agent: Agent[None, str] = Agent(model_name, system_prompt=full_system_prompt)
434446

435-
# Get response from the agent
436-
result = agent.run_sync(query)
447+
# Get response from the agent (async)
448+
result = await agent.run(query)
437449
return result.output
438450
except UnexpectedModelBehavior as e:
439451
log.error(f"Unexpected model behavior: {e}")
@@ -442,10 +454,10 @@ def _get_ai_response(self, query: str, trans: ProvidesUserContext, context_type:
442454
log.error(f"Error using pydantic-ai Agent: {e}")
443455
# Try fallback to direct OpenAI if available
444456
if openai is not None:
445-
return self._call_openai_directly(query, system_prompt, username)
457+
return await self._call_openai_directly(query, system_prompt, username)
446458
raise
447459

448-
def _call_openai_directly(self, query: str, system_prompt: str, username: str) -> str:
460+
async def _call_openai_directly(self, query: str, system_prompt: str, username: str) -> str:
449461
"""Direct OpenAI API call as fallback"""
450462
try:
451463
messages: list[dict[str, str]] = [
@@ -456,7 +468,8 @@ def _call_openai_directly(self, query: str, system_prompt: str, username: str) -
456468
},
457469
{"role": "user", "content": query},
458470
]
459-
response = openai.chat.completions.create(
471+
client = AsyncOpenAI()
472+
response = await client.chat.completions.create(
460473
model=self.config.ai_model,
461474
messages=messages, # type: ignore[arg-type]
462475
)

lib/galaxy/webapps/galaxy/api/proxy.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
"""
2-
API Controller to handle remote zip operations.
2+
API Controller to proxy remote files.
33
"""
44

55
import logging
6+
from functools import partial
67
from urllib.parse import (
78
urljoin,
89
urlparse,
910
)
1011

12+
import anyio
1113
import httpx
1214
from fastapi import (
1315
Query,
@@ -71,7 +73,7 @@ async def proxy(self, request: Request, url: str = URLQueryParam, trans: Provide
7173
if trans.anonymous:
7274
raise UserRequiredException("Anonymous users are not allowed to access this endpoint")
7375

74-
self._validate_url_and_access(url, trans)
76+
await anyio.to_thread.run_sync(partial(self._validate_url_and_access, url, trans))
7577

7678
headers: dict[str, str] = {}
7779
if "range" in request.headers:
@@ -151,7 +153,7 @@ async def _handle_redirects_validation(
151153
# Handle relative URLs by resolving them against the current URL
152154
redirect_url = urljoin(current_url, redirect_location)
153155

154-
self._validate_url_and_access(redirect_url, trans)
156+
await anyio.to_thread.run_sync(partial(self._validate_url_and_access, redirect_url, trans))
155157

156158
# Close current response and follow the validated redirect
157159
await response.aclose()

test/unit/app/tools/test_parameter_validation.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
History,
66
HistoryDatasetAssociation,
77
)
8+
from galaxy.tools.parameters.basic import ParameterValueError
89
from galaxy.util import galaxy_directory
910
from .util import BaseParameterTestCase
1011

@@ -415,3 +416,15 @@ def test_RegexValidator_global_flag_inline(self):
415416
p.validate("select id from job where id = 1;")
416417
with self.assertRaises(ValueError):
417418
p.validate("not sql")
419+
420+
def test_integer_to_python_raises_parameter_value_error(self):
421+
p = self._parameter_for(xml='<param name="num" type="integer" value="10" />')
422+
assert p.to_python(42, self.app) == 42
423+
with self.assertRaises(ParameterValueError):
424+
p.to_python(None, self.app)
425+
426+
def test_float_to_python_raises_parameter_value_error(self):
427+
p = self._parameter_for(xml='<param name="num" type="float" value="1.0" />')
428+
assert p.to_python(3.14, self.app) == 3.14
429+
with self.assertRaises(ParameterValueError):
430+
p.to_python(None, self.app)

0 commit comments

Comments
 (0)