Add post job action support to PickValueModule

Backend: PJA round-trip (from_dict, from_workflow_step, save_to_step,
get_post_job_actions) and execution via ActionBox.execute_on_mapped_over.
Added execute_on_mapped_over to ChangeDatatypeAction and ColumnSetAction
in post.py to enable jobless PJA execution.

Frontend: FormPickValue renders FormSection for PJA UI (rename, change
datatype, tags, columns). Wired through FormDefault and NodeInspector.

Framework tests: 4 workflow tests for change_datatype, rename, add_tag,
and multi-PJA (all three combined).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
John Chilton
2026-03-22 19:15:04 -04:00
co-authored by Claude Opus 4.6
parent 27d200e939
commit 700c991072
14 changed files with 310 additions and 17 deletions
@@ -46,7 +46,11 @@
<FormPickValue
v-if="type == 'pick_value'"
:step="step"
@onChange="onChange" />
:datatypes="datatypes"
:node-inputs="stepInputs"
:post-job-actions="postJobActions"
@onChange="onChange"
@onChangePostJobActions="onChangePostJobActions" />
<FormInputCollection
v-else-if="type == 'data_collection_input'"
:step="step"
@@ -104,9 +108,11 @@ const emit = defineEmits([
"onEditSubworkflow",
"onSetData",
"onUpdateStep",
"onChangePostJobActions",
]);
const stepRef = toRef(props, "step");
const { stepId, contentId, annotation, label, name, type, configForm } = useStepProps(stepRef);
const { stepId, contentId, annotation, label, name, type, configForm, stepInputs, postJobActions } =
useStepProps(stepRef);
const { stepStore } = useWorkflowStores();
const uniqueErrorLabel = useUniqueLabelError(stepStore, label.value);
const stepTitle = computed(() => {
@@ -132,6 +138,9 @@ function onEditSubworkflow() {
function onUpgradeSubworkflow() {
emit("onAttemptRefactor", [{ action_type: "upgrade_subworkflow", step: { order_index: stepId.value } }]);
}
function onChangePostJobActions(postJobActions: unknown) {
emit("onChangePostJobActions", stepId.value, postJobActions);
}
// keeps the component from emitting the onCreate change event
const initialChange = ref(true);
@@ -35,7 +35,7 @@ function makeStep(overrides: Partial<Step> = {}): Step {
}
function mountPickValue(step?: Step): Wrapper<Vue> {
return shallowMount(FormPickValue, {
return shallowMount(FormPickValue as any, {
propsData: {
step: step ?? makeStep(),
},
@@ -1,20 +1,33 @@
<script setup lang="ts">
import { toRef, watch } from "vue";
import type { Step } from "@/stores/workflowStepStore";
import type { DatatypesMapperModel } from "@/components/Datatypes/model";
import type { InputTerminalSource, PostJobActions, Step } from "@/stores/workflowStepStore";
import { useToolState } from "../composables/useToolState";
import FormElement from "@/components/Form/FormElement.vue";
import FormSection from "@/components/Workflow/Editor/Forms/FormSection.vue";
import Heading from "@/components/Common/Heading.vue";
interface ToolState {
mode: string;
num_inputs: number;
}
const props = defineProps<{
step: Step;
}>();
const props = withDefaults(
defineProps<{
step: Step;
datatypes?: DatatypesMapperModel["datatypes"];
nodeInputs?: InputTerminalSource[];
postJobActions?: PostJobActions;
}>(),
{
datatypes: undefined,
nodeInputs: () => [],
postJobActions: () => ({}),
}
);
const stepRef = toRef(props, "step");
const { toolState } = useToolState(stepRef);
@@ -34,7 +47,7 @@ function cleanToolState(): ToolState {
return { mode: "first_non_null", num_inputs: 2 };
}
const emit = defineEmits(["onChange"]);
const emit = defineEmits(["onChange", "onChangePostJobActions"]);
const modeOptions = [
["First non-null (error if all null)", "first_non_null"],
@@ -49,6 +62,10 @@ function onMode(newMode: string) {
emit("onChange", state);
}
function onChangePostJobActions(postJobActions: PostJobActions) {
emit("onChangePostJobActions", postJobActions);
}
// Grow-on-connect: watch step connections, add terminal when last empty one gets connected
watch(
() => props.step.input_connections,
@@ -77,5 +94,16 @@ emit("onChange", cleanToolState());
:options="modeOptions"
help="How to select among the connected inputs."
@input="onMode" />
<div v-if="datatypes && step.outputs && step.outputs.length > 0" class="mt-2 mb-4">
<Heading h2 separator bold size="sm"> Additional Options </Heading>
<FormSection
:id="step.id"
:node-inputs="nodeInputs ?? []"
:node-outputs="step.outputs"
:step="step"
:datatypes="datatypes"
:post-job-actions="postJobActions ?? {}"
@onChange="onChangePostJobActions" />
</div>
</div>
</template>
@@ -129,6 +129,7 @@ function updateStored(v: boolean) {
:datatypes="datatypes"
@onSetData="(id, d) => emit('dataChanged', id, d)"
@onUpdateStep="(id, s) => emit('stepUpdated', id, s)"
@onChangePostJobActions="(id, a) => emit('postJobActionsChanged', id, a)"
@onAnnotation="(id, a) => emit('annotationChanged', id, a)"
@onLabel="(id, l) => emit('labelChanged', id, l)"
@onEditSubworkflow="(id) => emit('editSubworkflow', id)"
+36 -9
View File
@@ -107,6 +107,21 @@ class ChangeDatatypeAction(DefaultJobAction):
name = "ChangeDatatypeAction"
verbose_name = "Change Datatype"
@classmethod
def execute_on_mapped_over(
cls, trans, sa_session, action, step_inputs, step_outputs, replacement_dict, final_job_state=None
):
if action.action_arguments and action.action_arguments.get("newtype"):
newtype = action.action_arguments["newtype"]
for name, step_output in step_outputs.items():
if action.output_name == "" or name == action.output_name:
if hasattr(step_output, "dataset_instances"):
for element in step_output.dataset_instances:
if element:
trans.app.datatypes_registry.change_datatype(element, newtype)
else:
trans.app.datatypes_registry.change_datatype(step_output, newtype)
@classmethod
def execute(cls, app, sa_session, action, job, replacement_dict=None, final_job_state=None):
if job.state == job.states.SKIPPED:
@@ -329,19 +344,31 @@ class ColumnSetAction(DefaultJobAction):
name = "ColumnSetAction"
verbose_name = "Assign Columns"
@classmethod
def _apply_column_set(cls, dataset, action_arguments):
for k, v in action_arguments.items():
if v:
if not isinstance(v, int):
if v[0] == "c":
v = v[1:]
v = int(v)
if v != 0:
setattr(dataset.metadata, k, v)
@classmethod
def execute_on_mapped_over(
cls, trans, sa_session, action, step_inputs, step_outputs, replacement_dict, final_job_state=None
):
if action.action_arguments:
for name, step_output in step_outputs.items():
if action.output_name == "" or name == action.output_name:
cls._apply_column_set(step_output, action.action_arguments)
@classmethod
def execute(cls, app, sa_session, action, job, replacement_dict=None, final_job_state=None):
for dataset_assoc in job.output_datasets:
if action.output_name == "" or dataset_assoc.name == action.output_name:
for k, v in action.action_arguments.items():
if v:
# Try to use both pure integer and 'cX' format.
if not isinstance(v, int):
if v[0] == "c":
v = v[1:]
v = int(v)
if v != 0:
setattr(dataset_assoc.dataset.metadata, k, v)
cls._apply_column_set(dataset_assoc.dataset, action.action_arguments)
@classmethod
def get_short_str(cls, pja):
+48
View File
@@ -1961,6 +1961,27 @@ class PickValueModule(WorkflowModule):
MODES = ("first_non_null", "first_or_skip", "the_only_non_null", "all_non_null")
def __init__(self, trans, content_id=None, **kwds):
super().__init__(trans, content_id=content_id, **kwds)
self.post_job_actions: dict[str, Any] = {}
@classmethod
def from_dict(Class, trans, d, **kwds):
module = super().from_dict(trans, d, **kwds)
module.post_job_actions = d.get("post_job_actions", {})
return module
@classmethod
def from_workflow_step(Class, trans, step, **kwds):
module = super().from_workflow_step(trans, step, **kwds)
module.post_job_actions = {}
for pja in step.post_job_actions:
module.post_job_actions[pja.action_type + pja.output_name] = pja
return module
def get_post_job_actions(self, incoming):
return self.post_job_actions
def get_inputs(self):
# State managed by frontend Vue component, not backend forms
return {}
@@ -1981,6 +2002,10 @@ class PickValueModule(WorkflowModule):
def save_to_step(self, step, detached=False):
step.type = self.type
step.tool_inputs = self._get_state_dict()
if not detached:
for k, v in self.post_job_actions.items():
pja = self._to_pja(k, v, step)
self.trans.sa_session.add(pja)
@property
def _num_inputs(self):
@@ -2093,6 +2118,7 @@ class PickValueModule(WorkflowModule):
raise ValueError(f"Unknown pick_value mode: {mode}")
progress.set_step_outputs(invocation_step, {"output": output})
self._apply_post_job_actions(trans, step, output, progress.effective_replacement_dict())
return None
def _create_skipped_output(self, trans, invocation_step):
@@ -2133,6 +2159,28 @@ class PickValueModule(WorkflowModule):
)
return hdca
def _apply_post_job_actions(self, trans, step, output, replacement_dict):
"""Apply post job actions directly to module output via ActionBox.
Uses execute_on_mapped_over which operates on step_outputs dict
rather than requiring a Job object.
"""
step_outputs = {"output": output}
step_inputs = {}
for pja in step.post_job_actions:
ActionBox.execute_on_mapped_over(
trans, trans.sa_session, pja, step_inputs, step_outputs, replacement_dict
)
@staticmethod
def _to_pja(key, value, step):
if isinstance(value, PostJobAction):
return value
output_name = value.get("output_name") if isinstance(value, dict) else None
action_arguments = value.get("action_arguments") if isinstance(value, dict) else None
action_type = value.get("action_type", key) if isinstance(value, dict) else key
return PostJobAction(action_type, step, output_name, action_arguments)
class ToolModule(WorkflowModule):
type = "tool"
@@ -0,0 +1,15 @@
- doc: |
Test that TagDatasetAction on pick_value adds a tag to the output.
job:
input_data:
type: File
value: 1.bed
file_type: bed
when:
type: raw
value: true
outputs:
picked:
class: File
metadata:
tags: "picktag"
@@ -0,0 +1,29 @@
class: GalaxyWorkflow
inputs:
input_data:
type: data
when:
type: boolean
outputs:
picked:
outputSource: pick/output
steps:
branch:
tool_id: cat
in:
input1:
source: input_data
when:
source: when
when: $(inputs.when)
pick:
type: pick_value
in:
input_0:
source: branch/out_file1
state:
mode: first_or_skip
out:
output:
add_tags:
- picktag
@@ -0,0 +1,17 @@
- doc: |
Test that ChangeDatatypeAction on pick_value changes output datatype.
job:
input_data:
type: File
value: 1.bed
file_type: bed
when:
type: raw
value: true
outputs:
picked:
class: File
ftype: txt
asserts:
- that: has_text
text: chr1
@@ -0,0 +1,28 @@
class: GalaxyWorkflow
inputs:
input_data:
type: data
when:
type: boolean
outputs:
picked:
outputSource: pick/output
steps:
branch:
tool_id: cat
in:
input1:
source: input_data
when:
source: when
when: $(inputs.when)
pick:
type: pick_value
in:
input_0:
source: branch/out_file1
state:
mode: first_or_skip
out:
output:
change_datatype: txt
@@ -0,0 +1,17 @@
- doc: |
Test multiple PJAs on pick_value: change datatype, rename, and add tag.
job:
input_data:
type: File
value: 1.bed
file_type: bed
when:
type: raw
value: true
outputs:
picked:
class: File
ftype: txt
metadata:
name: "picked_and_typed"
tags: "pv_tag"
@@ -0,0 +1,31 @@
class: GalaxyWorkflow
inputs:
input_data:
type: data
when:
type: boolean
outputs:
picked:
outputSource: pick/output
steps:
branch:
tool_id: cat
in:
input1:
source: input_data
when:
source: when
when: $(inputs.when)
pick:
type: pick_value
in:
input_0:
source: branch/out_file1
state:
mode: first_or_skip
out:
output:
change_datatype: txt
rename: "picked_and_typed"
add_tags:
- pv_tag
@@ -0,0 +1,15 @@
- doc: |
Test that RenameDatasetAction on pick_value renames the output.
job:
input_data:
type: File
value: 1.bed
file_type: bed
when:
type: raw
value: true
outputs:
picked:
class: File
metadata:
name: "picked_result"
@@ -0,0 +1,28 @@
class: GalaxyWorkflow
inputs:
input_data:
type: data
when:
type: boolean
outputs:
picked:
outputSource: pick/output
steps:
branch:
tool_id: cat
in:
input1:
source: input_data
when:
source: when
when: $(inputs.when)
pick:
type: pick_value
in:
input_0:
source: branch/out_file1
state:
mode: first_or_skip
out:
output:
rename: "picked_result"