From 85b9e1fa6e2c609efe70fac163c119e74c14c458 Mon Sep 17 00:00:00 2001 From: John Chilton <jmchilton@gmail.com> Date: Wed, 28 Aug 2024 10:37:09 -0400 Subject: [PATCH] [WIP] Use tool request API from the tool form. --- client/src/components/Tool/ToolForm.vue | 235 ++++--- client/src/components/Tool/ToolSuccess.vue | 5 + .../components/Tool/ToolSuccessMessage.vue | 36 +- client/src/components/Tool/parameterModels.ts | 305 +++++++++ .../src/components/Tool/parameter_models.yml | 610 ++++++++++++++++++ .../Tool/parameter_specification.yml | 1 + client/src/components/Tool/services.js | 21 + client/src/components/Tool/structured.test.ts | 79 +++ client/src/components/Tool/structured.ts | 397 ++++++++++++ client/src/stores/jobStore.ts | 1 + 10 files changed, 1606 insertions(+), 84 deletions(-) create mode 100644 client/src/components/Tool/parameterModels.ts create mode 100644 client/src/components/Tool/parameter_models.yml create mode 120000 client/src/components/Tool/parameter_specification.yml create mode 100644 client/src/components/Tool/structured.test.ts create mode 100644 client/src/components/Tool/structured.ts diff --git a/client/src/components/Tool/ToolForm.vue b/client/src/components/Tool/ToolForm.vue index 0268d65bad8e..827a95a22645 100644 --- a/client/src/components/Tool/ToolForm.vue +++ b/client/src/components/Tool/ToolForm.vue @@ -89,7 +89,7 @@ <template v-slot:header-buttons> <ButtonSpinner id="execute" - title="Run Tool" + :title="runButtonTitle" :disabled="!canMutateHistory" class="btn-sm" :wait="showExecuting" @@ -98,7 +98,7 @@ </template> <template v-slot:buttons> <ButtonSpinner - title="Run Tool" + :title="runButtonTitle" class="mt-3 mb-3" :disabled="!canMutateHistory" :wait="showExecuting" @@ -111,12 +111,14 @@ <script> import { getGalaxyInstance } from "app"; +import axios from "axios"; import ButtonSpinner from "components/Common/ButtonSpinner"; import Heading from "components/Common/Heading"; import FormDisplay from "components/Form/FormDisplay"; import FormElement from "components/Form/FormElement"; import LoadingSpan from "components/LoadingSpan"; import ToolEntryPoints from "components/ToolEntryPoints/ToolEntryPoints"; +import { getAppRoot } from "onload/loadConfig"; import { mapActions, mapState, storeToRefs } from "pinia"; import { useHistoryItemsStore } from "stores/historyItemsStore"; import { useJobStore } from "stores/jobStore"; @@ -128,7 +130,8 @@ import { useHistoryStore } from "@/stores/historyStore"; import { useUserStore } from "@/stores/userStore"; import ToolRecommendation from "../ToolRecommendation"; -import { getToolFormData, submitJob, updateToolFormData } from "./services"; +import { getToolFormData, getToolInputs, submitJob, submitToolRequest, updateToolFormData } from "./services"; +import { structuredInputs } from "./structured"; import ToolCard from "./ToolCard"; import { allowCachedJobs } from "./utilities"; @@ -204,6 +207,8 @@ export default { ], immutableHistoryMessage: "This history is immutable and you cannot run tools in it. Please switch to a different history.", + toolInputs: null, + submissionStateMessage: null, }; }, computed: { @@ -249,7 +254,15 @@ export default { return this.currentHistory && canMutateHistory(this.currentHistory); }, runButtonTitle() { - return "Run Tool"; + if (this.showExecuting) { + if (this.submissionStateMessage) { + return this.submissionStateMessage; + } else { + return "Run Tool"; + } + } else { + return "Run Tool"; + } }, }, watch: { @@ -301,11 +314,38 @@ export default { onChangeVersion(newVersion) { this.requestTool(newVersion); }, + waitOnRequest(response, requestContent, config, prevRoute) { + const toolRequestId = response.tool_request_id; + const handleRequestState = (toolRequestStateResponse) => { + const state = toolRequestStateResponse.data; + console.log(`state is ${state}`); + if (["new"].indexOf(state) !== -1) { + setTimeout(doRequestCheck, 1000); + } else if (state == "failed") { + this.handleError(null, requestContent); + } else { + refreshContentsWrapper(); + this.showForm = false; + this.showSuccess = true; + this.handleSubmissionComplete(config, prevRoute); + } + }; + const doRequestCheck = () => { + axios + .get(`${getAppRoot()}api/tool_requests/${toolRequestId}/state`) + .then(handleRequestState) + .catch((e) => this.handleError(e, requestContent)); + }; + setTimeout(doRequestCheck, 1000); + }, requestTool(newVersion) { this.currentVersion = newVersion || this.currentVersion; this.disabled = true; this.loading = true; console.debug("ToolForm - Requesting tool.", this.id); + getToolInputs(this.id, this.currentVersion).then((data) => { + this.toolInputs = data; + }); return getToolFormData(this.id, this.currentVersion, this.job_id, this.history_id) .then((data) => { this.formConfig = data; @@ -331,90 +371,135 @@ export default { onUpdatePreferredObjectStoreId(preferredObjectStoreId) { this.preferredObjectStoreId = preferredObjectStoreId; }, + handleSubmissionComplete(config, prevRoute) { + const changeRoute = prevRoute === this.$route.fullPath; + if (changeRoute) { + this.$router.push(`/jobs/submission/success`); + } else { + if ([true, "true"].includes(config.enable_tool_recommendations)) { + this.showRecommendation = true; + } + document.querySelector(".center-panel").scrollTop = 0; + } + }, + handleError(e, errorContent) { + this.errorMessage = e?.response?.data?.err_msg; + this.showExecuting = false; + this.submissionStateMessage = null; + let genericError = true; + const errorData = e && e.response && e.response.data && e.response.data.err_data; + if (errorData) { + const errorEntries = Object.entries(errorData); + if (errorEntries.length > 0) { + this.validationScrollTo = errorEntries[0]; + genericError = false; + } + } + if (genericError) { + this.showError = true; + this.errorTitle = "Job submission failed."; + this.errorContent = errorContent; + } + }, onExecute(config, historyId) { if (this.validationInternal) { this.validationScrollTo = this.validationInternal.slice(); return; } this.showExecuting = true; - const jobDef = { - history_id: historyId, - tool_id: this.formConfig.id, - tool_version: this.formConfig.version, - inputs: { - ...this.formData, - }, + this.submissionStateMessage = "Preparing Request"; + const inputs = { + ...this.formData, }; - if (this.useEmail) { - jobDef.inputs["send_email_notification"] = true; - } - if (this.useJobRemapping) { - jobDef.inputs["rerun_remap_job_id"] = this.job_id; - } - if (this.useCachedJobs) { - jobDef.inputs["use_cached_job"] = true; + const toolId = this.formConfig.id; + const toolVersion = this.formConfig.version; + let validatedInputs = null; + try { + validatedInputs = structuredInputs(inputs, this.toolInputs); + } catch { + // failed validation, just use legacy API } - if (this.preferredObjectStoreId) { - jobDef.preferred_object_store_id = this.preferredObjectStoreId; - } - if (this.dataManagerMode === "bundle") { - jobDef.data_manager_mode = this.dataManagerMode; - } - console.debug("toolForm::onExecute()", jobDef); const prevRoute = this.$route.fullPath; - submitJob(jobDef).then( - (jobResponse) => { - this.showExecuting = false; - let changeRoute = false; - refreshContentsWrapper(); - if (jobResponse.produces_entry_points) { - this.showEntryPoints = true; - this.entryPoints = jobResponse.jobs; - } - const nJobs = jobResponse && jobResponse.jobs ? jobResponse.jobs.length : 0; - if (nJobs > 0) { - this.showForm = false; - const toolName = this.toolName; - this.saveLatestResponse({ - jobDef, - jobResponse, - toolName, - }); - changeRoute = prevRoute === this.$route.fullPath; - } else { - this.showError = true; - this.showForm = true; - this.errorTitle = "Job submission rejected."; - this.errorContent = jobResponse; + if (validatedInputs) { + const toolRequest = { + history_id: historyId, + tool_id: toolId, + tool_version: toolVersion, + inputs: validatedInputs, + }; + if (this.useCachedJobs) { + toolRequest.use_cached_jobs = true; + } + if (this.preferredObjectStoreId) { + toolRequest.preferred_object_store_id = this.preferredObjectStoreId; + } + if (this.dataManagerMode === "bundle") { + toolRequest.data_manager_mode = this.dataManagerMode; + } + this.submissionStateMessage = "Sending Request"; + submitToolRequest(toolRequest).then( + (jobResponse) => { + this.submissionStateMessage = "Processing Request"; + console.log(jobResponse); + this.waitOnRequest(jobResponse, toolRequest, config, prevRoute); + }, + (e) => { + this.handleError(e, toolRequest); } - if (changeRoute) { - this.$router.push(`/jobs/submission/success`); - } else { - if ([true, "true"].includes(config.enable_tool_recommendations)) { - this.showRecommendation = true; + ); + } else { + const jobDef = { + history_id: historyId, + tool_id: toolId, + tool_version: toolVersion, + inputs: inputs, + }; + if (this.useEmail) { + jobDef.inputs["send_email_notification"] = true; + } + if (this.useJobRemapping) { + jobDef.inputs["rerun_remap_job_id"] = this.job_id; + } + if (this.useCachedJobs) { + jobDef.inputs["use_cached_job"] = true; + } + if (this.preferredObjectStoreId) { + jobDef.preferred_object_store_id = this.preferredObjectStoreId; + } + if (this.dataManagerMode === "bundle") { + jobDef.data_manager_mode = this.dataManagerMode; + } + console.debug("toolForm::onExecute()", jobDef); + submitJob(jobDef).then( + (jobResponse) => { + this.showExecuting = false; + refreshContentsWrapper(); + if (jobResponse.produces_entry_points) { + this.showEntryPoints = true; + this.entryPoints = jobResponse.jobs; } - document.querySelector("#center").scrollTop = 0; - } - }, - (e) => { - this.errorMessage = e?.response?.data?.err_msg; - this.showExecuting = false; - let genericError = true; - const errorData = e && e.response && e.response.data && e.response.data.err_data; - if (errorData) { - const errorEntries = Object.entries(errorData); - if (errorEntries.length > 0) { - this.validationScrollTo = errorEntries[0]; - genericError = false; + const nJobs = jobResponse && jobResponse.jobs ? jobResponse.jobs.length : 0; + if (nJobs > 0) { + this.showForm = false; + const toolName = this.toolName; + this.saveLatestResponse({ + jobDef, + jobResponse, + toolName, + }); + } else { + this.showError = true; + this.showForm = true; + this.errorTitle = "Job submission rejected."; + this.errorContent = jobResponse; } + this.handleSubmissionComplete(config, prevRoute); + }, + (e) => { + this.handleError(e, jobDef); } - if (genericError) { - this.showError = true; - this.errorTitle = "Job submission failed."; - this.errorContent = jobDef; - } - } - ); + ); + } }, }, }; diff --git a/client/src/components/Tool/ToolSuccess.vue b/client/src/components/Tool/ToolSuccess.vue index 8ac04d5c4c4d..2c446a3a148f 100644 --- a/client/src/components/Tool/ToolSuccess.vue +++ b/client/src/components/Tool/ToolSuccess.vue @@ -17,6 +17,7 @@ const jobStore = useJobStore(); const router = useRouter(); const jobDef = computed(() => responseVal.value.jobDef); +const usedToolRequest = computed(() => responseVal.value.usedToolRequest); const jobResponse = computed(() => responseVal.value.jobResponse); const responseVal = computed(() => jobStore.getLatestResponse); const showRecommendation = computed(() => config.value.enable_tool_recommendations); @@ -37,6 +38,10 @@ if (Object.keys(responseVal.value).length === 0) { <div v-if="jobResponse?.produces_entry_points"> <ToolEntryPoints v-for="job in jobResponse.jobs" :key="job.id" :job-id="job.id" /> </div> + <ToolSuccessMessage + :job-response="jobResponse" + :tool-name="toolName" + :used-tool-request="usedToolRequest" /> <ToolSuccessMessage :job-response="jobResponse" :tool-name="toolName" /> <Webhook type="tool" :tool-id="jobDef.tool_id" /> <ToolRecommendation v-if="showRecommendation" :tool-id="jobDef.tool_id" /> diff --git a/client/src/components/Tool/ToolSuccessMessage.vue b/client/src/components/Tool/ToolSuccessMessage.vue index e5b9faf0a090..867af32c659d 100644 --- a/client/src/components/Tool/ToolSuccessMessage.vue +++ b/client/src/components/Tool/ToolSuccessMessage.vue @@ -1,14 +1,28 @@ <template> <div class="donemessagelarge"> - <p> - Started tool <b>{{ toolName }}</b> and successfully added {{ nJobsText }} to the queue. - </p> - <p>It produces {{ nOutputsText }}:</p> - <ul> - <li v-for="item of jobResponse.outputs" :key="item.hid"> - <b>{{ item.hid }}: {{ item.name }}</b> - </li> - </ul> + <div v-if="usedToolRequest"> + You used the fancy new API... something new will be here. + <img + src="https://www.animatedimages.org/data/media/695/animated-under-construction-image-0055.gif" + alt="90s style under construction" /> + </div> + <div v-else> + <p> + Started tool <b>{{ toolName }}</b> and successfully added {{ nJobsText }} to the queue. + </p> + <p>The tool uses {{ nInputsText }}:</p> + <ul> + <li v-for="item of inputs" :key="item.hid"> + <b>{{ item.hid }}: {{ item.name }}</b> + </li> + </ul> + <p>It produces {{ nOutputsText }}:</p> + <ul> + <li v-for="item of jobResponse.outputs" :key="item.hid"> + <b>{{ item.hid }}: {{ item.name }}</b> + </li> + </ul> + </div> <p> You can check the status of queued jobs and view the resulting data by refreshing the History panel. When the job has been run the status will change from 'running' to 'finished' if completed successfully or @@ -28,6 +42,10 @@ export default { type: String, required: true, }, + usedToolRequest: { + type: Boolean, + required: true, + }, }, computed: { nOutputs() { diff --git a/client/src/components/Tool/parameterModels.ts b/client/src/components/Tool/parameterModels.ts new file mode 100644 index 000000000000..d85cc14e2b17 --- /dev/null +++ b/client/src/components/Tool/parameterModels.ts @@ -0,0 +1,305 @@ +/* tslint:disable */ +/* eslint-disable */ +/** +/* This file was automatically generated from pydantic models by running pydantic2ts. +/* Do not modify it by hand - just update the pydantic models and then re-run the script +*/ + +export type ToolParameterModel = + | TextParameterModel + | IntegerParameterModel + | FloatParameterModel + | BooleanParameterModel + | HiddenParameterModel + | SelectParameterModel + | DataParameterModel + | DataCollectionParameterModel + | DirectoryUriParameterModel + | RulesParameterModel + | ColorParameterModel + | ConditionalParameterModel + | RepeatParameterModel + | CwlIntegerParameterModel + | CwlFloatParameterModel + | CwlStringParameterModel + | CwlBooleanParameterModel + | CwlNullParameterModel + | CwlFileParameterModel + | CwlDirectoryParameterModel + | CwlUnionParameterModel; + +export interface BaseGalaxyToolParameterModelDefinition { + name: string; + parameter_type: string; + hidden?: boolean; + label?: string; + help?: string; + argument?: string; + refresh_on_change?: boolean; + optional?: boolean; + is_dynamic?: boolean; +} +export interface BaseToolParameterModelDefinition { + name: string; + parameter_type: string; +} +export interface BooleanParameterModel { + name: string; + parameter_type?: "gx_boolean"; + hidden?: boolean; + label?: string; + help?: string; + argument?: string; + refresh_on_change?: boolean; + optional?: boolean; + is_dynamic?: boolean; + value?: boolean; + truevalue?: string; + falsevalue?: string; +} +export interface ColorParameterModel { + name: string; + parameter_type?: "gx_color"; + hidden?: boolean; + label?: string; + help?: string; + argument?: string; + refresh_on_change?: boolean; + optional?: boolean; + is_dynamic?: boolean; +} +export interface ConditionalParameterModel { + name: string; + parameter_type?: "gx_conditional"; + hidden?: boolean; + label?: string; + help?: string; + argument?: string; + refresh_on_change?: boolean; + optional?: boolean; + is_dynamic?: boolean; + test_parameter: BooleanParameterModel | SelectParameterModel; + whens: ConditionalWhen[]; +} +export interface SelectParameterModel { + name: string; + parameter_type?: "gx_select"; + hidden?: boolean; + label?: string; + help?: string; + argument?: string; + refresh_on_change?: boolean; + optional?: boolean; + is_dynamic?: boolean; + options?: LabelValue[]; + multiple: boolean; +} +export interface LabelValue { + label: string; + value: string; +} +export interface ConditionalWhen { + discriminator: boolean | string; + parameters: ToolParameterModel[]; +} +export interface TextParameterModel { + name: string; + parameter_type?: "gx_text"; + hidden?: boolean; + label?: string; + help?: string; + argument?: string; + refresh_on_change?: boolean; + optional?: boolean; + is_dynamic?: boolean; + area?: boolean; + value?: string; + default_options?: LabelValue[]; +} +export interface IntegerParameterModel { + name: string; + parameter_type?: "gx_integer"; + hidden?: boolean; + label?: string; + help?: string; + argument?: string; + refresh_on_change?: boolean; + optional: boolean; + is_dynamic?: boolean; + value?: number; + min?: number; + max?: number; +} +export interface FloatParameterModel { + name: string; + parameter_type?: "gx_float"; + hidden?: boolean; + label?: string; + help?: string; + argument?: string; + refresh_on_change?: boolean; + optional?: boolean; + is_dynamic?: boolean; + value?: number; + min?: number; + max?: number; +} +export interface HiddenParameterModel { + name: string; + parameter_type?: "gx_hidden"; + hidden?: boolean; + label?: string; + help?: string; + argument?: string; + refresh_on_change?: boolean; + optional?: boolean; + is_dynamic?: boolean; +} +export interface DataParameterModel { + name: string; + parameter_type?: "gx_data"; + hidden?: boolean; + label?: string; + help?: string; + argument?: string; + refresh_on_change?: boolean; + optional?: boolean; + is_dynamic?: boolean; + extensions?: string[]; + multiple?: boolean; + min?: number; + max?: number; +} +export interface DataCollectionParameterModel { + name: string; + parameter_type?: "gx_data_collection"; + hidden?: boolean; + label?: string; + help?: string; + argument?: string; + refresh_on_change?: boolean; + optional?: boolean; + is_dynamic?: boolean; + collection_type?: string; + extensions?: string[]; +} +export interface DirectoryUriParameterModel { + name: string; + parameter_type: "gx_directory_uri"; + hidden?: boolean; + label?: string; + help?: string; + argument?: string; + refresh_on_change?: boolean; + optional?: boolean; + is_dynamic?: boolean; + value?: string; +} +export interface RulesParameterModel { + name: string; + parameter_type?: "gx_rules"; + hidden?: boolean; + label?: string; + help?: string; + argument?: string; + refresh_on_change?: boolean; + optional?: boolean; + is_dynamic?: boolean; +} +export interface RepeatParameterModel { + name: string; + parameter_type?: "gx_repeat"; + hidden?: boolean; + label?: string; + help?: string; + argument?: string; + refresh_on_change?: boolean; + optional?: boolean; + is_dynamic?: boolean; + parameters: ToolParameterModel[]; +} +export interface CwlIntegerParameterModel { + name: string; + parameter_type?: "cwl_integer"; +} +export interface CwlFloatParameterModel { + name: string; + parameter_type?: "cwl_float"; +} +export interface CwlStringParameterModel { + name: string; + parameter_type?: "cwl_string"; +} +export interface CwlBooleanParameterModel { + name: string; + parameter_type?: "cwl_boolean"; +} +export interface CwlNullParameterModel { + name: string; + parameter_type?: "cwl_null"; +} +export interface CwlFileParameterModel { + name: string; + parameter_type?: "cwl_file"; + hidden?: boolean; + label?: string; + help?: string; + argument?: string; + refresh_on_change?: boolean; + optional?: boolean; + is_dynamic?: boolean; +} +export interface CwlDirectoryParameterModel { + name: string; + parameter_type?: "cwl_directory"; + hidden?: boolean; + label?: string; + help?: string; + argument?: string; + refresh_on_change?: boolean; + optional?: boolean; + is_dynamic?: boolean; +} +export interface CwlUnionParameterModel { + name: string; + parameter_type?: "cwl_union"; + parameters: ToolParameterModel[]; +} +export interface DataCollectionRequest { + src: "hdca"; + id: string; +} +export interface DataCollectionRequestInternal { + src: "hdca"; + id: number; +} +export interface DataRequest { + src: "hda" | "ldda"; + id: string; +} +export interface DataRequestInteranl { + src: "hda" | "ldda"; + id: number; +} +export interface MultiDataInstance { + src: "hda" | "ldda" | "hdca"; + id: string; +} +export interface MultiDataInstanceInternal { + src: "hda" | "ldda" | "hdca"; + id: number; +} +export interface RulesMapping { + type: string; + columns: number[]; +} +export interface RulesModel { + rules: { + [k: string]: unknown; + }[]; + mappings: RulesMapping[]; +} +export interface StrictModel {} +export interface ToolParameterBundleModel { + input_models: ToolParameterModel[]; +} diff --git a/client/src/components/Tool/parameter_models.yml b/client/src/components/Tool/parameter_models.yml new file mode 100644 index 000000000000..5ca2a43ccba8 --- /dev/null +++ b/client/src/components/Tool/parameter_models.yml @@ -0,0 +1,610 @@ +# auto generated file for JavaScript testing, do not modify manually +# ----- +# PYTHONPATH="lib" python test/unit/tool_util/test_parameter_specification.py +# ----- +cwl_boolean: + name: parameter + parameter_type: cwl_boolean +cwl_boolean_optional: + name: parameter + parameter_type: cwl_union + parameters: + - name: parameter + parameter_type: cwl_null + - name: parameter + parameter_type: cwl_boolean +cwl_directory: + argument: null + help: null + hidden: false + is_dynamic: false + label: null + name: parameter + optional: false + parameter_type: cwl_directory + refresh_on_change: false +cwl_file: + argument: null + help: null + hidden: false + is_dynamic: false + label: null + name: parameter + optional: false + parameter_type: cwl_file + refresh_on_change: false +cwl_float: + name: parameter + parameter_type: cwl_float +cwl_float_optional: + name: parameter + parameter_type: cwl_union + parameters: + - name: parameter + parameter_type: cwl_null + - name: parameter + parameter_type: cwl_float +cwl_int: + name: parameter + parameter_type: cwl_integer +cwl_int_optional: + name: parameter + parameter_type: cwl_union + parameters: + - name: parameter + parameter_type: cwl_null + - name: parameter + parameter_type: cwl_integer +cwl_string: + name: parameter + parameter_type: cwl_string +cwl_string_optional: + name: parameter + parameter_type: cwl_union + parameters: + - name: parameter + parameter_type: cwl_null + - name: parameter + parameter_type: cwl_string +gx_boolean: + argument: null + falsevalue: null + help: null + hidden: false + is_dynamic: false + label: null + name: parameter + optional: false + parameter_type: gx_boolean + refresh_on_change: false + truevalue: null + value: false +gx_color: + argument: null + help: null + hidden: false + is_dynamic: false + label: null + name: parameter + optional: false + parameter_type: gx_color + refresh_on_change: false + value: '#aabbcc' +gx_conditional_boolean: + argument: null + help: null + hidden: false + is_dynamic: false + label: null + name: conditional_parameter + optional: false + parameter_type: gx_conditional + refresh_on_change: false + test_parameter: + argument: null + falsevalue: null + help: null + hidden: false + is_dynamic: false + label: null + name: test_parameter + optional: false + parameter_type: gx_boolean + refresh_on_change: false + truevalue: null + value: false + whens: + - discriminator: 'true' + is_default_when: false + parameters: + - argument: null + help: null + hidden: false + is_dynamic: false + label: null + max: null + min: null + name: integer_parameter + optional: false + parameter_type: gx_integer + refresh_on_change: false + value: 1 + - discriminator: 'false' + is_default_when: true + parameters: + - argument: null + falsevalue: null + help: null + hidden: false + is_dynamic: false + label: null + name: boolean_parameter + optional: false + parameter_type: gx_boolean + refresh_on_change: false + truevalue: null + value: false +gx_conditional_boolean_checked: + argument: null + help: null + hidden: false + is_dynamic: false + label: null + name: conditional_parameter + optional: false + parameter_type: gx_conditional + refresh_on_change: false + test_parameter: + argument: null + falsevalue: null + help: null + hidden: false + is_dynamic: false + label: null + name: test_parameter + optional: false + parameter_type: gx_boolean + refresh_on_change: false + truevalue: null + value: true + whens: + - discriminator: 'true' + is_default_when: true + parameters: + - argument: null + help: null + hidden: false + is_dynamic: false + label: null + max: null + min: null + name: integer_parameter + optional: false + parameter_type: gx_integer + refresh_on_change: false + value: 1 + - discriminator: 'false' + is_default_when: false + parameters: + - argument: null + falsevalue: null + help: null + hidden: false + is_dynamic: false + label: null + name: boolean_parameter + optional: false + parameter_type: gx_boolean + refresh_on_change: false + truevalue: null + value: false +gx_conditional_conditional_boolean: + argument: null + help: null + hidden: false + is_dynamic: false + label: null + name: outer_conditional_parameter + optional: false + parameter_type: gx_conditional + refresh_on_change: false + test_parameter: + argument: null + falsevalue: null + help: null + hidden: false + is_dynamic: false + label: null + name: outer_test_parameter + optional: false + parameter_type: gx_boolean + refresh_on_change: false + truevalue: null + value: false + whens: + - discriminator: 'true' + is_default_when: false + parameters: + - argument: null + help: null + hidden: false + is_dynamic: false + label: null + name: inner_conditional_parameter + optional: false + parameter_type: gx_conditional + refresh_on_change: false + test_parameter: + argument: null + falsevalue: null + help: null + hidden: false + is_dynamic: false + label: null + name: inner_test_parameter + optional: false + parameter_type: gx_boolean + refresh_on_change: false + truevalue: null + value: false + whens: + - discriminator: 'true' + is_default_when: false + parameters: + - argument: null + help: null + hidden: false + is_dynamic: false + label: null + max: null + min: null + name: integer_parameter + optional: false + parameter_type: gx_integer + refresh_on_change: false + value: 1 + - discriminator: 'false' + is_default_when: true + parameters: + - argument: null + falsevalue: null + help: null + hidden: false + is_dynamic: false + label: null + name: boolean_parameter + optional: false + parameter_type: gx_boolean + refresh_on_change: false + truevalue: null + value: false + - discriminator: 'false' + is_default_when: true + parameters: + - argument: null + falsevalue: null + help: null + hidden: false + is_dynamic: false + label: null + name: boolean_parameter + optional: false + parameter_type: gx_boolean + refresh_on_change: false + truevalue: null + value: false +gx_data: + argument: null + extensions: + - data + help: null + hidden: false + is_dynamic: false + label: null + max: null + min: null + multiple: false + name: parameter + optional: false + parameter_type: gx_data + refresh_on_change: false +gx_data_collection: + argument: null + collection_type: null + extensions: + - data + help: null + hidden: false + is_dynamic: false + label: null + name: parameter + optional: false + parameter_type: gx_data_collection + refresh_on_change: false +gx_data_collection_optional: + argument: null + collection_type: null + extensions: + - data + help: null + hidden: false + is_dynamic: false + label: null + name: parameter + optional: true + parameter_type: gx_data_collection + refresh_on_change: false +gx_data_multiple: + argument: null + extensions: + - data + help: null + hidden: false + is_dynamic: false + label: null + max: null + min: null + multiple: true + name: parameter + optional: false + parameter_type: gx_data + refresh_on_change: false +gx_data_multiple_optional: + argument: null + extensions: + - data + help: null + hidden: false + is_dynamic: false + label: null + max: null + min: null + multiple: true + name: parameter + optional: true + parameter_type: gx_data + refresh_on_change: false +gx_data_optional: + argument: null + extensions: + - data + help: null + hidden: false + is_dynamic: false + label: null + max: null + min: null + multiple: false + name: parameter + optional: true + parameter_type: gx_data + refresh_on_change: false +gx_float: + argument: null + help: null + hidden: false + is_dynamic: false + label: null + max: null + min: null + name: parameter + optional: false + parameter_type: gx_float + refresh_on_change: false + value: 1.0 +gx_float_optional: + argument: null + help: null + hidden: false + is_dynamic: false + label: null + max: null + min: null + name: parameter + optional: true + parameter_type: gx_float + refresh_on_change: false + value: null +gx_hidden: + argument: null + help: null + hidden: false + is_dynamic: false + label: null + name: parameter + optional: false + parameter_type: gx_hidden + refresh_on_change: false +gx_hidden_optional: + argument: null + help: null + hidden: false + is_dynamic: false + label: null + name: parameter + optional: true + parameter_type: gx_hidden + refresh_on_change: false +gx_int: + argument: null + help: null + hidden: false + is_dynamic: false + label: null + max: null + min: null + name: parameter + optional: false + parameter_type: gx_integer + refresh_on_change: false + value: 1 +gx_int_optional: + argument: null + help: null + hidden: false + is_dynamic: false + label: null + max: null + min: null + name: parameter + optional: true + parameter_type: gx_integer + refresh_on_change: false + value: 1 +gx_repeat_boolean: + argument: null + help: null + hidden: false + is_dynamic: false + label: null + name: parameter + optional: false + parameter_type: gx_repeat + parameters: + - argument: null + falsevalue: null + help: null + hidden: false + is_dynamic: false + label: null + name: boolean_parameter + optional: false + parameter_type: gx_boolean + refresh_on_change: false + truevalue: null + value: false + refresh_on_change: false +gx_select: + argument: null + help: null + hidden: false + is_dynamic: false + label: null + multiple: false + name: parameter + optional: false + options: + - label: Ex1 + selected: false + value: --ex1 + - label: Ex2 + selected: false + value: ex2 + - label: Ex3 + selected: false + value: --ex3 + - label: Ex4 + selected: false + value: --ex4 + - label: Ex5 + selected: false + value: ex5 + parameter_type: gx_select + refresh_on_change: false +gx_select_multiple: + argument: null + help: null + hidden: false + is_dynamic: false + label: null + multiple: true + name: parameter + optional: false + options: + - label: Ex1 + selected: false + value: --ex1 + - label: Ex2 + selected: false + value: ex2 + - label: Ex3 + selected: false + value: --ex3 + - label: Ex4 + selected: false + value: --ex4 + - label: Ex5 + selected: false + value: ex5 + parameter_type: gx_select + refresh_on_change: false +gx_select_multiple_optional: + argument: null + help: null + hidden: false + is_dynamic: false + label: null + multiple: true + name: parameter + optional: true + options: + - label: Ex1 + selected: false + value: --ex1 + - label: Ex2 + selected: false + value: ex2 + - label: Ex3 + selected: false + value: --ex3 + - label: Ex4 + selected: false + value: --ex4 + - label: Ex5 + selected: false + value: ex5 + parameter_type: gx_select + refresh_on_change: false +gx_select_optional: + argument: null + help: null + hidden: false + is_dynamic: false + label: null + multiple: false + name: parameter + optional: true + options: + - label: Ex1 + selected: false + value: --ex1 + - label: Ex2 + selected: false + value: ex2 + - label: Ex3 + selected: false + value: --ex3 + - label: Ex4 + selected: false + value: --ex4 + - label: Ex5 + selected: false + value: ex5 + parameter_type: gx_select + refresh_on_change: false +gx_text: + area: false + argument: null + default_options: [] + default_value: null + help: null + hidden: false + is_dynamic: false + label: null + name: parameter + optional: false + parameter_type: gx_text + refresh_on_change: false +gx_text_optional: + area: false + argument: null + default_options: [] + default_value: null + help: null + hidden: false + is_dynamic: false + label: null + name: parameter + optional: true + parameter_type: gx_text + refresh_on_change: false diff --git a/client/src/components/Tool/parameter_specification.yml b/client/src/components/Tool/parameter_specification.yml new file mode 120000 index 000000000000..0a9f7915c276 --- /dev/null +++ b/client/src/components/Tool/parameter_specification.yml @@ -0,0 +1 @@ +../../../../test/unit/tool_util/parameter_specification.yml \ No newline at end of file diff --git a/client/src/components/Tool/services.js b/client/src/components/Tool/services.js index 4bf2d5e3702a..14ec852231af 100644 --- a/client/src/components/Tool/services.js +++ b/client/src/components/Tool/services.js @@ -18,6 +18,21 @@ export async function updateToolFormData(tool_id, tool_version, history_id, inpu } } +export async function getToolInputs(tool_id, tool_version) { + let url = ""; + url = `${getAppRoot()}api/tools/${tool_id}/inputs`; + if (tool_version) { + url += `?tool_version=${tool_version}`; + } + // request tool data + try { + const { data } = await axios.get(url); + return data; + } catch (e) { + rethrowSimple(e); + } +} + /** Tools data request helper **/ export async function getToolFormData(tool_id, tool_version, job_id, history_id) { let url = ""; @@ -59,3 +74,9 @@ export async function submitJob(jobDetails) { const { data } = await axios.post(url, jobDetails); return data; } + +export async function submitToolRequest(toolRequest) { + const url = `${getAppRoot()}api/jobs`; + const { data } = await axios.post(url, toolRequest); + return data; +} diff --git a/client/src/components/Tool/structured.test.ts b/client/src/components/Tool/structured.test.ts new file mode 100644 index 000000000000..345257c7b1ae --- /dev/null +++ b/client/src/components/Tool/structured.test.ts @@ -0,0 +1,79 @@ +import SPEC_PARAMETERS from "./parameter_models.yml"; +import SPEC_TESTS from "./parameter_specification.yml"; +import { type TextParameterModel, type ToolParameterModel } from "./parameterModels"; +import { structuredInputs, validate } from "./structured"; + +describe("structured.js", () => { + it("should parse galaxy integer parameters", () => { + expect(true).toBe(true); + const si = structuredInputs({ parameter: "5" }, [ + { name: "parameter", parameter_type: "gx_integer", optional: false }, + ]); + expect(si).toHaveProperty("parameter"); + if ("parameter" in si) { + const val = si["parameter"]; + expect(val).toBe(5); + } + }); +}); + +type TestCase = { [ParameterName: string]: any }; + +interface FileTestCases { + request_invalid: Array<TestCase>; + request_valid: Array<TestCase>; +} + +type ParameterSpecification = { [FileName: string]: FileTestCases }; + +function itShouldValidateParameters(file: string, parameters: Array<TestCase>) { + for (const [index, parameter] of parameters.entries()) { + itShouldValidateParameter(file, index, parameter); + } +} + +function itShouldInvalidateParameters(file: string, parameters: Array<TestCase>) { + for (const [index, parameter] of parameters.entries()) { + itShouldInvalidateParameter(file, index, parameter); + } +} + +function parameterModelsForFile(filename: string): Array<ToolParameterModel> { + const parameterModel = SPEC_PARAMETERS[filename]; + const parameterObject: ToolParameterModel = parameterModel as TextParameterModel; + const inputs = [parameterObject]; + return inputs; +} + +function itShouldValidateParameter(file: string, index: number, parameterTestCase: TestCase) { + let doc = " for file [" + file + "] and valid parameter combination [" + index + "]"; + if (parameterTestCase._doc) { + doc = " - " + parameterTestCase._doc; + } + it("should validate example parameter request (from parameter_spec.yml)" + doc, () => { + const result = validate(parameterTestCase, parameterModelsForFile(file)); + expect(result).toBe(null); + }); +} + +function itShouldInvalidateParameter(file: string, index: number, parameterTestCase: TestCase) { + let doc = " for file [" + file + "] and invalid parameter combination [" + index + "]"; + if (parameterTestCase._doc) { + doc = " - " + parameterTestCase._doc; + } + it("should fail validation of example parameter request (from parameter_spec.yml)" + doc, () => { + const result = validate(parameterTestCase, parameterModelsForFile(file)); + expect(result).not.toBe(null); + }); +} + +describe("Tool Parameter Specification", () => { + for (const [file, testCases] of Object.entries(SPEC_TESTS as ParameterSpecification)) { + if (testCases.request_valid) { + itShouldValidateParameters(file, testCases.request_valid); + } + if (testCases.request_invalid) { + itShouldInvalidateParameters(file, testCases.request_invalid); + } + } +}); diff --git a/client/src/components/Tool/structured.ts b/client/src/components/Tool/structured.ts new file mode 100644 index 000000000000..153523cf9a00 --- /dev/null +++ b/client/src/components/Tool/structured.ts @@ -0,0 +1,397 @@ +import type { + BooleanParameterModel, + ColorParameterModel, + ConditionalParameterModel, + CwlBooleanParameterModel, + CwlDirectoryParameterModel, + CwlFileParameterModel, + CwlFloatParameterModel, + CwlIntegerParameterModel, + CwlNullParameterModel, + CwlStringParameterModel, + CwlUnionParameterModel, + DataCollectionParameterModel, + DataParameterModel, + FloatParameterModel, + HiddenParameterModel, + IntegerParameterModel, + RepeatParameterModel, + SelectParameterModel, + TextParameterModel, + ToolParameterModel, +} from "./parameterModels"; + +type StructuredInputs = { [parameterName: string]: any }; +type FormInputs = { [parameterName: string]: any }; + +export function structuredInputs(formInputs: FormInputs, toolInputs: Array<ToolParameterModel>): StructuredInputs { + const structuredInputs: StructuredInputs = {}; + for (const toolInput of toolInputs) { + const inputKey = toolInput.name; + structuredInputs[inputKey] = parseInt(formInputs[inputKey]); + } + const validationResult = validateParameters(structuredInputs, toolInputs); + if (validationResult.length !== 0) { + console.log(`Failed structured input validation with... '${validationResult}'`); + throw Error("Failed parameter validation"); + } + return structuredInputs; +} + +function extendValidationResults(results: Array<string>, withResults: Array<string>) { + withResults.forEach((obj) => { + results.push(obj); + }); +} + +function isFloat(v: any) { + return typeof v == "number"; +} + +function isString(v: any) { + return typeof v == "string"; +} + +function isGxInteger(model: ToolParameterModel): model is IntegerParameterModel { + return model.parameter_type == "gx_integer"; +} + +function isGxFloat(model: ToolParameterModel): model is FloatParameterModel { + return model.parameter_type == "gx_float"; +} + +function isGxText(model: ToolParameterModel): model is TextParameterModel { + return model.parameter_type == "gx_text"; +} + +function isGxBoolean(model: ToolParameterModel): model is BooleanParameterModel { + return model.parameter_type == "gx_boolean"; +} + +function isGxHidden(model: ToolParameterModel): model is HiddenParameterModel { + return model.parameter_type == "gx_hidden"; +} + +function isGxSelect(model: ToolParameterModel): model is SelectParameterModel { + return model.parameter_type == "gx_select"; +} + +function isGxColor(model: ToolParameterModel): model is ColorParameterModel { + return model.parameter_type == "gx_color"; +} + +function isGxData(model: ToolParameterModel): model is DataParameterModel { + return model.parameter_type == "gx_data"; +} + +function isGxDataCollection(model: ToolParameterModel): model is DataCollectionParameterModel { + return model.parameter_type == "gx_data_collection"; +} + +function isGxRepeat(model: ToolParameterModel): model is RepeatParameterModel { + return model.parameter_type == "gx_repeat"; +} + +function isGxConditional(model: ToolParameterModel): model is ConditionalParameterModel { + return model.parameter_type == "gx_conditional"; +} + +function isCwlInteger(model: ToolParameterModel): model is CwlIntegerParameterModel { + return model.parameter_type == "cwl_integer"; +} + +function isCwlDirectory(model: ToolParameterModel): model is CwlDirectoryParameterModel { + return model.parameter_type == "cwl_directory"; +} + +function isCwlFile(model: ToolParameterModel): model is CwlFileParameterModel { + return model.parameter_type == "cwl_file"; +} + +function isCwlUnion(model: ToolParameterModel): model is CwlUnionParameterModel { + return model.parameter_type == "cwl_union"; +} + +function isCwlNull(model: ToolParameterModel): model is CwlNullParameterModel { + return model.parameter_type == "cwl_null"; +} + +function isCwlBoolean(model: ToolParameterModel): model is CwlBooleanParameterModel { + return model.parameter_type == "cwl_boolean"; +} + +function isCwlString(model: ToolParameterModel): model is CwlStringParameterModel { + return model.parameter_type == "cwl_string"; +} + +function isCwlFloat(model: ToolParameterModel): model is CwlFloatParameterModel { + return model.parameter_type == "cwl_float"; +} + +const isBool = (v: any) => { + return typeof v == "boolean"; +}; + +function isObjectWithKeys(inputObject: any, requiredKeys: Array<string>): boolean { + if (!inputObject) { + return false; + } + if (typeof inputObject != "object") { + return false; + } + for (const inputKey of Object.keys(inputObject)) { + if (requiredKeys.indexOf(inputKey) == -1) { + return false; + } + } + for (const requiredKey of requiredKeys) { + if (!(requiredKey in inputObject)) { + return false; + } + } + return true; +} + +function isSrcReferenceObject(v: any, srcTypes: Array<string>) { + return isObjectWithKeys(v, ["src", "id"]) && srcTypes.indexOf(v.src) >= 0 && isString(v.id); +} + +type TypeChecker = (v: any) => boolean; + +function isArrayOf(inputObject: any, typeCheck: TypeChecker): boolean { + if (!Array.isArray(inputObject)) { + return false; + } + for (const el of inputObject) { + if (!typeCheck(el)) { + return false; + } + } + return true; +} + +const isDataDict = (v: any) => { + return isSrcReferenceObject(v, ["hda", "ldda"]); +}; + +const isBatch = (v: any, valueChecker: TypeChecker) => { + if (!isObjectWithKeys(v, ["__class__", "values"]) || v["__class__"] != "Batch") { + return false; + } + const values = v.values; + return isArrayOf(values, valueChecker); +}; + +function simpleCwlTypeChecker(parameterModel: ToolParameterModel) { + let checkType = null; + if (isCwlInteger(parameterModel)) { + checkType = Number.isInteger; + } else if (isCwlDirectory(parameterModel)) { + checkType = isDataDict; + } else if (isCwlFile(parameterModel)) { + checkType = isDataDict; + } else if (isCwlNull(parameterModel)) { + checkType = (v: any) => { + return v == null; + }; + } else if (isCwlBoolean(parameterModel)) { + checkType = isBool; + } else if (isCwlString(parameterModel)) { + checkType = isString; + } else if (isCwlFloat(parameterModel)) { + checkType = isFloat; + } else { + throw Error("Unknown simple CWL type encountered."); + } + return checkType; +} + +function checkCwlUnionType(parameterModel: CwlUnionParameterModel, inputValue: any) { + for (const unionedModel of parameterModel.parameters) { + if (simpleCwlTypeChecker(unionedModel)(inputValue)) { + return true; + } + } + return false; +} + +function validateParameter(inputKey: string, inputValue: any, parameterModel: ToolParameterModel) { + const results: string[] = []; + let checkType = null; + + function handleOptional(typeCheck: TypeChecker, parameter: ToolParameterModel) { + if ("optional" in parameter && parameter.optional) { + return (v: any) => { + return v === null || typeCheck(v); + }; + } else { + return typeCheck; + } + } + + if (isGxInteger(parameterModel)) { + checkType = handleOptional(Number.isInteger, parameterModel); + } else if (isGxFloat(parameterModel)) { + checkType = handleOptional(isFloat, parameterModel); + } else if (isGxText(parameterModel)) { + checkType = handleOptional(isString, parameterModel); + } else if (isGxBoolean(parameterModel)) { + checkType = handleOptional(isBool, parameterModel); + } else if (isGxHidden(parameterModel)) { + checkType = handleOptional(isString, parameterModel); + } else if (isGxColor(parameterModel)) { + const isColorString = (v: any) => { + return isString(v) && /^#[0-9A-F]{6}$/i.test(v); + }; + checkType = handleOptional(isColorString, parameterModel); + } else if (isGxData(parameterModel)) { + const isMultiDataDict = (v: any) => { + return isSrcReferenceObject(v, ["hda", "ldda", "hdca"]); + }; + const isArrayOfDataDict = (v: any) => { + return isArrayOf(v, isMultiDataDict); + }; + const isBatchData = (v: any) => { + return isBatch(v, isMultiDataDict); + }; + let checkRaw: TypeChecker; + if (parameterModel.multiple) { + checkRaw = handleOptional((v) => { + return isMultiDataDict(v) || isArrayOfDataDict(v); + }, parameterModel); + } else { + checkRaw = isDataDict; + } + checkType = (v: any) => { + return checkRaw(v) || isBatchData(v); + }; + checkType = handleOptional(checkType, parameterModel); + } else if (isGxSelect(parameterModel)) { + let isElement: TypeChecker; + if (parameterModel.options != null) { + const optionValues = parameterModel.options.map((lv) => { + return lv.value; + }); + const isOneOfOptions = (v: any) => { + return isString(v) && optionValues.indexOf(v) !== -1; + }; + isElement = isOneOfOptions; + } else { + isElement = isString; + } + if (parameterModel.multiple) { + checkType = (v: any) => { + return isArrayOf(v, isElement); + }; + } else { + checkType = isElement; + } + checkType = handleOptional(checkType, parameterModel); + } else if (isGxDataCollection(parameterModel)) { + const isDataCollectionDict = (v: any) => { + return isSrcReferenceObject(v, ["hdca"]); + }; + checkType = handleOptional(isDataCollectionDict, parameterModel); + } else if (isCwlInteger(parameterModel)) { + checkType = simpleCwlTypeChecker(parameterModel); + } else if (isCwlDirectory(parameterModel)) { + checkType = simpleCwlTypeChecker(parameterModel); + } else if (isCwlFile(parameterModel)) { + checkType = simpleCwlTypeChecker(parameterModel); + } else if (isCwlUnion(parameterModel)) { + checkType = (v: any) => { + return checkCwlUnionType(parameterModel, v); + }; + } else if (isCwlBoolean(parameterModel)) { + checkType = simpleCwlTypeChecker(parameterModel); + } else if (isCwlString(parameterModel)) { + checkType = simpleCwlTypeChecker(parameterModel); + } else if (isCwlFloat(parameterModel)) { + checkType = simpleCwlTypeChecker(parameterModel); + } else if (isGxRepeat(parameterModel)) { + if (!Array.isArray(inputValue)) { + results.push(`Parameter ${inputKey} is not an array of values.`); + } else { + for (const inputs of inputValue) { + const instanceResults = validateParameters(inputs, parameterModel.parameters); + extendValidationResults(results, instanceResults); + } + } + } else if (isGxConditional(parameterModel)) { + const testParameter = parameterModel.test_parameter; + let testParameterEffectivelyOptional = testParameter.optional; + if (!testParameterEffectivelyOptional && "value" in testParameter && testParameter.value !== null) { + testParameterEffectivelyOptional = true; + } + const whens = parameterModel.whens; + const testParameterName = testParameter.name; + const testParameterValue = inputValue[testParameterName]; + // validateParameter(testParameterName, testParameterValue, testParameter); + let testParameterValueFoundInWhen = false; + for (const when of whens) { + const inputKey = when.discriminator; + if (inputKey === testParameterValue) { + testParameterValueFoundInWhen = true; + const whenParameters = when.parameters.concat([testParameter]); + const whenResults = validateParameters(inputValue, whenParameters); + extendValidationResults(results, whenResults); + break; + } + } + if (!testParameterValueFoundInWhen && !testParameterEffectivelyOptional) { + results.push(`Non optional conditional test parameter ${testParameterName} was not found in inputs.`); + } + } + if (checkType && !checkType(inputValue)) { + results.push(`Parameter ${inputKey} is of invalid type.`); + } + return results; +} + +function validateParameters( + structuredInputs: StructuredInputs, + parameterModels: Array<ToolParameterModel> +): Array<string> { + const results = []; + const keysEncountered = []; + const parameterModelsByName: { [name: string]: ToolParameterModel } = {}; + parameterModels.forEach((v) => { + parameterModelsByName[v.name] = v; + }); + for (const inputKey of Object.keys(structuredInputs)) { + keysEncountered.push(inputKey); + if (!(inputKey in parameterModelsByName)) { + results.push(`Unknown parameter ${inputKey} encountered.`); + continue; + } + const inputValue = structuredInputs[inputKey]; + const parameterModel = parameterModelsByName[inputKey]; + if (parameterModel) { + const parameterResults = validateParameter(inputKey, inputValue, parameterModel); + extendValidationResults(results, parameterResults); + } + } + for (const parameterModel of parameterModels) { + const inputKey = parameterModel.name; + if (keysEncountered.indexOf(inputKey) !== -1) { + continue; + } + const toolInput = parameterModelsByName[inputKey]; + if (toolInput && "optional" in toolInput && toolInput.optional === true) { + continue; + } + if (toolInput && "value" in toolInput && toolInput.value !== null) { + continue; + } + if (isGxConditional(parameterModel)) { + continue; + } + results.push(`Non optional parameter ${inputKey} was not found in inputs.`); + } + return results; +} + +export function validate(structuredInputs: object, toolInputs: Array<ToolParameterModel>) { + const results = validateParameters(structuredInputs, toolInputs); + return results.length == 0 ? null : results; +} diff --git a/client/src/stores/jobStore.ts b/client/src/stores/jobStore.ts index bbae01bdf6f4..880bbb290683 100644 --- a/client/src/stores/jobStore.ts +++ b/client/src/stores/jobStore.ts @@ -28,6 +28,7 @@ interface ResponseVal { jobDef: JobDef; jobResponse: JobResponse; toolName: string; + usedToolRequest: boolean; } export const useJobStore = defineStore("jobStore", {