History Graph UI Integration — Backend Prep Plan
Date: 2026-05-22 Branch:
workflow_state_backfillPredecessors (carried, untouched): CAPTURE_WORKFLOW_EXECUTION_STATE_PLAN (Phase 1: converter + capture-time machinery) and FINISH_EXEC_STATE_PLAN (tool_execution_stateschema + resolver + unified producer pass). Downstream consumer: PR #22752 — History Graph UI by @guerler. He rebases on top after this lands; he drops his backend commits. Tracking comment: posted to galaxyproject/galaxy#22710 on 2026-05-22.
Why this exists
Two parallel branches converge above the persistence boundary (same convert.py, same request.py, same _capture_workflow_tool_request_state) but diverge at where the payload lands: workflow_state_backfill puts it on a tool_execution_state row reached via three FKs (the EXEC_STATE shape); #22752 mints ToolRequest(state IS NULL) rows for workflow steps and reuses Job.tool_request_id (MINT). EXEC_STATE wins for the lower levels; mvdbeek + @jmchilton aligned in the issue thread.
Most of #22752’s backend goes away on rebase. Three pieces of it don’t — they’re orthogonal to MINT-vs-EXEC_STATE and solid on their own merits. They land on workflow_state_backfill here, before the PR opens, so reviewers see one coherent backend layer with the integration pieces already in place. The History Graph UI then rebases with one endpoint swap + a TS-guided rename.
Settled decisions
- Integration in this branch, not a follow-up sequence. The three #22752 cherry-picks plus the new endpoint plus the wire rename land on
workflow_state_backfilldirectly, ahead of the PR. Single coherent backend layer for review. - Endpoint shape: new
GET /api/tool_executions/{id}sibling to/api/tool_requests/{id}, same response shape astool_request_to_model. Source-neutral overToolExecutionState. Backed by the existing resolver. - Wire rename:
GraphNode.srcliteral"tool_request"→"tool_execution"ships in the same commit as the endpoint. They describe the same concept change; stranding the cosmetic mismatch is uglier than the rename itself.GraphNode.srcis an openapi Literal — TS compiler points at every UI site after schema regen. /api/tool_requests/*stays. It’s still the right shape for the async-submission lifecycle (NEW / SUBMITTED / FAILED / state polling). The new endpoint is read-only for the captured payload; it does not replace the lifecycle endpoints.- ToolSource UQ ships with the cherry-pick batch. Real bug fix — existing async-API mint inserts a fresh
ToolSourcerow per request withhash="TODO"literally hardcoded. UQ +get_or_create_tool_sourcefills the TODO and dedupes silently. Independent of EXEC_STATE; worth on its own.
Steps
1. Cherry-pick dataset_element edges + SYNTHETIC_TOOL_IDS
-
lib/galaxy/managers/history_graph.py: addSYNTHETIC_TOOL_IDS: tuple[str, ...] = ("__DATA_FETCH__",); replace the twoJob.tool_id != "__DATA_FETCH__"filters withJob.tool_id.notin_(SYNTHETIC_TOOL_IDS). Generalizes the existing exclusion. -
lib/galaxy/managers/history_graph.py: add_collection_element_edges(hdca_ids)walker (lifted verbatim from #2275252d7f72af7). Returnsset[tuple[hda_id, hdca_id]]for visible leaf HDAs under each top-level HDCA. Suppresses hidden elements (mirrors_remove_hidden_elements). -
lib/galaxy/managers/history_graph.py: inbuild()after step 3, emit adataset_elementedge per(hda_id, hdca_id)and add to closure if missing. -
lib/galaxy/managers/history_graph.py: extendEDGE_TYPE_RANKwith"dataset_element": 4. -
lib/galaxy/schema/history_graph.py: extendGraphEdge.typeLiteral with"dataset_element". -
test/unit/app/managers/test_HistoryGraphBuilder.py: add cases — visible HDA in HDCA → edge present; hidden HDA in HDCA → edge absent; nested child collection → leaf HDA wires to top-level HDCA. - Regenerate openapi schema (
make client-formator whatever invokes the schema regen).
2. ToolSource lookup-or-create + UQ
- New migration
<rev>_add_tool_source_hash_source_class_uq.pyafter28885b317f78:- Dedupe step first. Existing dev DBs have many
ToolSourcerows withhash="TODO". Collapse duplicate(hash, source_class)pairs to the oldest id; repointtool_request.tool_source_idreferences; delete the losers. SQL’d viaop.execute(...)blocks (PG + SQLite shapes). - Then
op.create_unique_constraint("uq_tool_source_hash_source_class", "tool_source", ["hash", "source_class"]). - Downgrade drops the UQ; does not un-dedupe.
- Dedupe step first. Existing dev DBs have many
-
lib/galaxy/model/__init__.py: add__table_args__ = (UniqueConstraint("hash", "source_class"),)toToolSource. - New
lib/galaxy/managers/tool_source.pywithget_or_create_tool_source(session, tool) -> ToolSource. Lifted verbatim from #22752 (managers/tool_source.py): sha256 the source string, lookup by(hash, source_class), race-safe viaIntegrityErrorretry. -
lib/galaxy/webapps/galaxy/services/jobs.py:268-286: drop the inlineToolSource(...)construction + the literalhash="TODO"; callget_or_create_tool_source(sa_session, tool). Drops onesa_session.add(tool_source_model). - Test: a new
test/unit/app/managers/test_tool_source.py— lookup-or-create returns same row across two calls;IntegrityErrorretry path returns the winning row. - Integration verification on a dev DB with pre-existing
hash="TODO"rows: migration runs clean, ToolRequest FKs intact.
3. New endpoint GET /api/tool_executions/{id} + wire rename
- Backend route in
lib/galaxy/webapps/galaxy/api/tools.py(or a newtool_executions.pyrouter if the existing module is too crowded). Path/api/tool_executions/{id}. Returns the sameToolRequestModelshapetool_request_to_modelreturns today, sourced from aToolExecutionStaterow. - Service method in
lib/galaxy/webapps/galaxy/services/base.pyor a sibling —tool_execution_to_model(tes, security)analog oftool_request_to_model. Reuses_encode_tool_requestpayload-walk logic (rename to_encode_request_payloador similar; share between both endpoint sides). - Schema model in
lib/galaxy/schema/schema.py:ToolExecutionModel(id, create_time, update_time, request, state). Probably a subset of the existing tool-request model minus the async lifecycle fields. - Wire rename in the same commit:
lib/galaxy/schema/history_graph.py:GraphNode.srcLiteral"tool_request"→"tool_execution".lib/galaxy/managers/history_graph.py:NODE_SRC["tool_request"]→"tool_execution";TYPE_RANKkey rename;_producer_refliteral;_producer_nodesliteral; then.src == "tool_request"filter in_producer_nodes.test/unit/app/managers/test_HistoryGraphBuilder.py: every assertion ofsrc=="tool_request"→"tool_execution". Mechanical.- Regenerate openapi schema →
client/src/api/schema/schema.tsgets the literal flip. TS compiler now flags every UI site for guerler’s rebase pass (not this branch’s concern).
- Integration test
test/integration/test_workflow_invocation.py(or a newtest_tool_executions.py): hit/api/tool_executions/{tes_id}for both a workflow-step-captured TES and an async-API-minted TES; assert payload shape + state.
4. Verify cleanly + open PR
-
tox -e unit -- test/unit/app/managers/test_HistoryGraphBuilder.py test/unit/app/managers/test_tool_source.py -
./run_tests.sh -integration test/integration/test_workflow_invocation.py -
./run_tests.sh -api lib/galaxy_test/api/test_workflow_extraction.py(regression — extraction still resolves via the seam unchanged) - Manual GUI smoke check: history graph endpoint returns wire-renamed
src; producer node renders. - Open PR. Description summarizes: capture workflow tool-step request state in
tool_execution_state, unify the History Graph resolver, prep wire vocabulary + endpoint for the History Graph UI in #22752.
Files to touch
| File | Step | Scope |
|---|---|---|
lib/galaxy/managers/history_graph.py | 1, 3 | dataset_element walker + edge; wire-rename src literal |
lib/galaxy/schema/history_graph.py | 1, 3 | GraphEdge.type += "dataset_element"; GraphNode.src literal flip |
lib/galaxy/model/__init__.py (ToolSource) | 2 | __table_args__ = (UniqueConstraint(...),) |
lib/galaxy/model/migrations/alembic/versions_gxy/<rev>_add_tool_source_hash_source_class_uq.py | 2 | dedupe + UQ |
lib/galaxy/managers/tool_source.py | 2 | new — get_or_create_tool_source |
lib/galaxy/webapps/galaxy/services/jobs.py (~268) | 2 | call helper, drop hash="TODO" |
lib/galaxy/webapps/galaxy/services/base.py | 3 | rename _encode_tool_request → _encode_request_payload; add tool_execution_to_model |
lib/galaxy/webapps/galaxy/api/tools.py (or new module) | 3 | route /api/tool_executions/{id} |
lib/galaxy/schema/schema.py | 3 | ToolExecutionModel |
test/unit/app/managers/test_HistoryGraphBuilder.py | 1, 3 | edge cases; tool_request → tool_execution |
test/unit/app/managers/test_tool_source.py | 2 | new — helper happy path + race |
test/integration/test_workflow_invocation.py (or new file) | 3 | endpoint integration |
client/src/api/schema/schema.ts | 3 | regenerated |
What guerler picks up on rebase (documented for handoff, not in this PR)
client/src/components/History/Graph/HistoryGraphNodeDetails.vue:50→ swap/api/tool_requests/{id}for/api/tool_executions/{id}.- Rename pass driven by TS compiler errors after schema regen: every
"tool_request"literal underclient/src/components/History/Graph/→"tool_execution"(~10 sites in 4 files). - CSS class
node-tool-request→node-tool-execution(1 selector inHistoryGraphOverview.vue). - File
HistoryGraphToolRequests.vue→HistoryGraphToolExecutions.vue+ ident renametoolRequestNodes/isToolRequest. - Drop his backend commits (
340a70f23e,771bf5cc73,0cba6fa7b1,a1ea6c4d8c,52d7f72af7) on rebase. Convert.py / request.py / capture-time bits in workflow_state_backfill supersede them.
Out of scope
- Dropping
ToolRequest.requestcolumn + the dual-write — follow-up PR per FINISH_EXEC_STATE_PLAN. - Repointing
ToolRequestImplicitCollectionAssociationontoToolExecutionState— follow-up. - Backfilling historical workflow invocations — never; documented in FINISH_EXEC_STATE_PLAN.
ToolRequestImplicitCollectionAssociationschema work,tool_sourcesnapshot column on TES, etc. — all deferred per CAPTURE_WORKFLOW_EXECUTION_STATE_PLAN resolutions.
Unresolved questions
- Response shape
ToolExecutionModelvs reuseToolRequestModelminus async fields. Concrete diff isstate_message+ the lifecyclestateenum (NEW/SUBMITTED/FAILED). Either works; pick before route lands. ToolSourcededupe migration — risk on production-sized DBs? Worth a quick row-count sanity check (SELECT count(*), hash, source_class FROM tool_source GROUP BY hash, source_class HAVING count(*) > 1) before running.- Wire rename: ship in same commit as endpoint, or separate? Recommend same (atomic semantic shift); flag if reviewers prefer split.
- Endpoint module home:
api/tools.py(crowded) vs newapi/tool_executions.py. Lean new module. - Should
/api/tool_executions/{id}accept aToolRequestid transitionally (for historical rows where TES FK is NULL)? Or hard 404 and force the UI to fall back to/api/tool_requests/{id}for historical nodes via cipher-kind discrimination? Probably hard 404 — cleaner, and the resolver already returns the legacy payload via the dual-writeToolRequest.requestpath for anyToolExecutionState-backed read.