Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 100 additions & 1 deletion client/src/api/schema/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6306,6 +6306,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/workflows/from_iwc": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Import an IWC workflow into the user's stored workflows by TRS id. */
post: operations["import_from_iwc_api_workflows_from_iwc_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/workflows/menu": {
parameters: {
query?: never;
Expand Down Expand Up @@ -7236,7 +7253,13 @@ export interface components {
* @description Types of actions agents can suggest.
* @enum {string}
*/
ActionType: "tool_run" | "save_tool" | "contact_support" | "view_external" | "documentation";
ActionType:
| "tool_run"
| "save_tool"
| "contact_support"
| "view_external"
| "documentation"
| "workflow_import";
/** AddInputAction */
AddInputAction: {
/**
Expand Down Expand Up @@ -15579,6 +15602,37 @@ export interface components {
[key: string]: number;
};
};
/** ImportFromIwcPayload */
ImportFromIwcPayload: {
/**
* TRS ID
* @description TRS ID of the workflow in the IWC manifest. Example: "#workflow/github.com/iwc-workflows/rna-seq/main".
*/
trs_id: string;
};
/** ImportFromIwcResponse */
ImportFromIwcResponse: {
/**
* Id
* @description Encoded id of the imported StoredWorkflow.
*/
id: string;
/**
* Missing Tools
* @description Tool ids referenced by the workflow that are not currently installed. Non-empty means the workflow imported but cannot run until an admin installs them.
*/
missing_tools?: string[];
/**
* Name
* @description Name of the imported StoredWorkflow.
*/
name: string;
/**
* Trsid
* @description TRS ID this workflow was imported from.
*/
trsID: string;
};
/** ImportToolDataBundle */
ImportToolDataBundle: {
/** Source */
Expand Down Expand Up @@ -50028,6 +50082,51 @@ export interface operations {
};
};
};
import_from_iwc_api_workflows_from_iwc_post: {
parameters: {
query?: never;
header?: {
/** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */
"run-as"?: string | null;
};
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ImportFromIwcPayload"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ImportFromIwcResponse"];
};
};
/** @description Request Error */
"4XX": {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MessageExceptionModel"];
};
};
/** @description Server Error */
"5XX": {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MessageExceptionModel"];
};
};
};
};
get_workflow_menu_api_workflows_menu_get: {
parameters: {
query?: {
Expand Down
2 changes: 2 additions & 0 deletions client/src/components/ChatGXY/ActionCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import {
faBook,
faExternalLinkAlt,
faFileImport,
faLifeRing,
faPencilAlt,
faPlay,
Expand Down Expand Up @@ -59,6 +60,7 @@ const iconMap: Record<ActionType, IconDefinition> = {
[ActionType.REFINE_QUERY]: faPencilAlt,
[ActionType.DOCUMENTATION]: faBook,
[ActionType.VIEW_EXTERNAL]: faExternalLinkAlt,
[ActionType.WORKFLOW_IMPORT]: faFileImport,
};

function getIcon(actionType: ActionType): IconDefinition {
Expand Down
37 changes: 37 additions & 0 deletions client/src/composables/agentActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export enum ActionType {
VIEW_EXTERNAL = "view_external",
SAVE_TOOL = "save_tool",
REFINE_QUERY = "refine_query",
WORKFLOW_IMPORT = "workflow_import",
}
/* eslint-enable no-unused-vars */

Expand Down Expand Up @@ -79,6 +80,10 @@ export function useAgentActions() {
handleDocumentation(action);
break;

case ActionType.WORKFLOW_IMPORT:
await handleWorkflowImport(action);
break;

default:
// Unknown actions default to contact support
console.warn(`Unknown action type: ${action.action_type}, redirecting to support`);
Expand Down Expand Up @@ -178,6 +183,37 @@ export function useAgentActions() {
toast.success("Opening in new tab");
}

/**
* Handle WORKFLOW_IMPORT action - import an IWC workflow by trsID
*/
async function handleWorkflowImport(action: ActionSuggestion) {
const trsId = action.parameters.trs_id;
const name = action.parameters.name || "IWC workflow";

if (!trsId) {
toast.error("No trs_id provided for workflow import action");
return;
}

const { data, error } = await GalaxyApi().POST("/api/workflows/from_iwc", {
body: { trs_id: trsId },
});

if (error) {
toast.error(`Failed to import ${name}: ${String(error)}`);
return;
}

if (data.missing_tools && data.missing_tools.length > 0) {
toast.warning(
`${data.name} imported, but ${data.missing_tools.length} tool(s) are not installed on this server.`,
);
} else {
toast.success(`Imported ${data.name} from IWC`);
}
router.push(`/workflows/edit?id=${data.id}`);
}

/**
* Handle DOCUMENTATION action - open tool documentation
*/
Expand Down Expand Up @@ -212,6 +248,7 @@ export function useAgentActions() {
[ActionType.CONTACT_SUPPORT]: "🆘",
[ActionType.REFINE_QUERY]: "✏️",
[ActionType.VIEW_EXTERNAL]: "🔗",
[ActionType.WORKFLOW_IMPORT]: "📥",
};
return icons[actionType] || "❓";
}
Expand Down
169 changes: 169 additions & 0 deletions lib/galaxy/agents/iwc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
"""IWC (Intergalactic Workflows Commission) manifest fetching and search helpers.

Manifest fetches go through a process-wide TTL cache so the per-request
``AgentOperationsManager`` instances all share the same hit. Pre-warming
the cache via celery beat is a reasonable follow-up.
"""

import logging
import re
from threading import Lock
from typing import (
Any,
Optional,
)

from cachetools import TTLCache

from galaxy.util import requests

log = logging.getLogger(__name__)

IWC_MANIFEST_URL = "https://iwc.galaxyproject.org/workflow_manifest.json"
CACHE_TTL_SECONDS = 60 * 60 # one hour
_CACHE_KEY = "manifest"

_manifest_cache: TTLCache = TTLCache(maxsize=1, ttl=CACHE_TTL_SECONDS)
_manifest_cache_lock = Lock()


def clear_manifest_cache() -> None:
"""Reset the manifest cache. Tests use this; production normally won't."""
with _manifest_cache_lock:
_manifest_cache.clear()


def fetch_manifest(timeout: float = 30.0) -> list[dict[str, Any]]:
Comment thread
dannon marked this conversation as resolved.
"""Fetch the IWC manifest, returning a cached copy when fresh.

The lock is held across the network fetch so concurrent cold misses
share a single in-flight request rather than each issuing their own.
"""
with _manifest_cache_lock:
cached = _manifest_cache.get(_CACHE_KEY)
if cached is not None:
return cached

response = requests.get(IWC_MANIFEST_URL, timeout=timeout)
response.raise_for_status()
manifest = response.json()
if not isinstance(manifest, list):
raise ValueError(f"IWC manifest at {IWC_MANIFEST_URL} did not return a JSON array")
_manifest_cache[_CACHE_KEY] = manifest
return manifest


def all_workflows(manifest: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Flatten the manifest into a single list of workflow entries."""
workflows: list[dict[str, Any]] = []
for entry in manifest:
workflows.extend(entry.get("workflows", []) or [])
return workflows


def clean_readme_summary(readme: str, max_length: int = 300) -> str:
if not readme:
return ""
lines: list[str] = []
for line in readme.split("\n"):
if line.strip().startswith("#"):
continue
if not lines and not line.strip():
continue
lines.append(line)
text = " ".join(" ".join(lines).split())
if len(text) > max_length:
text = text[: max_length - 3].rsplit(" ", 1)[0] + "..."
return text


def extract_tool_names_from_steps(steps: dict[str, Any]) -> list[str]:
seen: set[str] = set()
out: list[str] = []
for step in steps.values():
if not isinstance(step, dict):
continue
tool_id = step.get("tool_id")
if not tool_id:
continue
# toolshed format: server/repos/owner/<name>/<name>/<version> -> <name>
parts = tool_id.split("/")
name = parts[-2] if len(parts) > 1 else tool_id
if name and name not in seen:
seen.add(name)
out.append(name)
return out


def enrich_workflow(workflow: dict[str, Any], include_full_readme: bool = False) -> dict[str, Any]:
definition = workflow.get("definition", {}) or {}
readme = workflow.get("readme", "") or ""
creators = definition.get("creator") or []
authors = []
if isinstance(creators, list):
authors = [
{"name": c.get("name", ""), "orcid": c.get("identifier", "")} for c in creators if isinstance(c, dict)
]
steps = definition.get("steps", {}) or {}
result: dict[str, Any] = {
"trsID": workflow.get("trsID", ""),
"name": definition.get("name", ""),
"description": definition.get("annotation", ""),
"tags": definition.get("tags", []) or [],
"readme_summary": clean_readme_summary(readme),
"step_count": len(steps) if isinstance(steps, dict) else 0,
"authors": authors,
"categories": workflow.get("categories", []) or [],
"tools_used": extract_tool_names_from_steps(steps),
}
if include_full_readme:
result["readme"] = readme
return result


_TOKEN_RE = re.compile(r"[A-Za-z0-9]+")


def _tokenize(text: str) -> list[str]:
return [t.lower() for t in _TOKEN_RE.findall(text or "")]


def _score(query_tokens: list[str], text: str) -> int:
if not query_tokens:
return 0
text_tokens = set(_tokenize(text))
return sum(1 for t in query_tokens if t in text_tokens)


def search_workflows(workflows: list[dict[str, Any]], query: str, limit: Optional[int] = None) -> list[dict[str, Any]]:
"""Rank workflows by token overlap against name/description/readme/tags.

Each returned entry has ``match_score`` attached so callers can surface
ranking confidence (mirrors galaxy-mcp's recommend_iwc_workflows shape).
"""
query_tokens = _tokenize(query)
if not query_tokens:
return []

scored: list[tuple[int, dict[str, Any]]] = []
for wf in workflows:
enriched = enrich_workflow(wf)
haystack = " ".join(
[
enriched["name"],
enriched["description"],
" ".join(enriched["tags"]),
enriched["readme_summary"],
" ".join(enriched["tools_used"]),
]
)
score = _score(query_tokens, haystack)
if score > 0:
enriched["match_score"] = score
scored.append((score, enriched))

scored.sort(key=lambda pair: pair[0], reverse=True)
results = [enriched for _, enriched in scored]
if limit is not None:
results = results[:limit]
return results
Loading
Loading