import EventEmitter from "events"; import type { DatatypesMapperModel } from "@/components/Datatypes/model"; import type { useWorkflowStores } from "@/composables/workflowStores"; import { getConnectionId } from "@/stores/workflowConnectionStore"; import { type CollectionOutput, type DataCollectionStepInput, type DataOutput, type DataStepInput, getCombinedStepInputs, type ParameterOutput, type ParameterStepInput, type TerminalSource, } from "@/stores/workflowStepStore"; import type { Connection, ConnectionId } from "@/stores/workflowStoreTypes"; import { assertDefined } from "@/utils/assertions"; import { ANY_COLLECTION_TYPE_DESCRIPTION, CollectionTypeDescription, type CollectionTypeDescriptor, NULL_COLLECTION_TYPE_DESCRIPTION, } from "./collectionTypeDescription"; export const NO_COLLECTION_TYPE_INFORMATION_MESSAGE = "No collection type or collection type source defined - this is fine but may lead to less intuitive connection logic."; export class ConnectionAcceptable { reason: string | null; canAccept: boolean; constructor(canAccept: boolean, reason: string | null) { this.canAccept = canAccept; this.reason = reason; } } interface BaseTerminalArgs { name: string; stepId: number; datatypesMapper: DatatypesMapperModel; stores: ReturnType; } interface InputTerminalInputs { datatypes: string[]; multiple: boolean; optional: boolean; } interface InputTerminalArgs extends BaseTerminalArgs { input: InputTerminalInputs; input_type: "dataset" | "dataset_collection" | "parameter"; } class Terminal extends EventEmitter { stores; name: string; multiple: boolean; stepId: number; terminalType: "input" | "output"; datatypesMapper: DatatypesMapperModel; localMapOver: CollectionTypeDescriptor; constructor(attr: BaseTerminalArgs) { super(); this.stores = attr.stores; this.stepId = attr.stepId; this.name = attr.name; this.multiple = false; this.terminalType = "input"; this.datatypesMapper = attr.datatypesMapper; this.localMapOver = NULL_COLLECTION_TYPE_DESCRIPTION; } public get id() { return `node-${this.stepId}-${this.terminalType}-${this.name}`; } public get connections(): Connection[] { return this.stores.connectionStore.getConnectionsForTerminal(this.id); } public get mapOver(): CollectionTypeDescriptor { return this.stores.stepStore.stepMapOver[this.stepId] || NULL_COLLECTION_TYPE_DESCRIPTION; } buildConnection(other: Terminal | Connection) { let connection: Connection; if (other instanceof Terminal) { connection = { input: { stepId: this.stepId, name: this.name, connectorType: "input" }, output: { stepId: other.stepId, name: other.name, connectorType: "output" }, }; } else { connection = other; } return connection; } connect(other: Terminal | Connection) { this.stores.undoRedoStore .action() .onRun(() => this.makeConnection(other)) .onUndo(() => this.dropConnection(other)) .setName("connect steps") .apply(); } makeConnection(other: Terminal | Connection) { const connection = this.buildConnection(other); this.stores.connectionStore.addConnection(connection); } disconnect(other: Terminal | Connection) { this.stores.undoRedoStore .action() .onRun(() => this.dropConnection(other)) .onUndo(() => this.makeConnection(other)) .setName("disconnect steps") .apply(); } dropConnection(other: Terminal | Connection) { const connection = this.buildConnection(other); this.stores.connectionStore.removeConnection(getConnectionId(connection)); this.resetMappingIfNeeded(connection); } setMapOver(val: CollectionTypeDescriptor) { // we use this method to determine the map over status based on all terminals connected to this step let outputVal = val; if (this.multiple) { // emulate list input const description = new CollectionTypeDescription("list"); if (val.collectionType === description.collectionType) { // No mapping over necessary return; } outputVal = val.effectiveMapOver(description); } const effectiveMapOver = this._effectiveMapOver(outputVal); if (!this.localMapOver.equal(effectiveMapOver)) { this.stores.stepStore.changeStepInputMapOver(this.stepId, this.name, effectiveMapOver); this.localMapOver = effectiveMapOver; } if ( !this.mapOver.equal(effectiveMapOver) && (effectiveMapOver.isCollection || !Object.values(this.stores.stepStore.stepInputMapOver[this.stepId] ?? []).find( (mapOver) => mapOver.isCollection, )) ) { this.stores.stepStore.changeStepMapOver(this.stepId, effectiveMapOver); } } _effectiveMapOver(otherCollectionType: CollectionTypeDescriptor) { return otherCollectionType; } isMappedOver(): boolean { return Boolean(this.mapOver.isCollection); } resetMapping(_connection?: Connection) { this.stores.stepStore.changeStepMapOver(this.stepId, NULL_COLLECTION_TYPE_DESCRIPTION); this.stores.stepStore.resetStepInputMapOver(this.stepId); } hasConnectedMappedInputTerminals() { // check if step has connected and mapped input terminals ... should maybe be on step/node ? const connections = this.stores.connectionStore.getConnectionsForStep(this.stepId); return connections.some( (connection) => connection.input.stepId === this.stepId && this.stores.stepStore.stepMapOver[this.stepId]?.collectionType, ); } _getOutputConnections() { return this.stores.connectionStore.getConnectionsForStep(this.stepId).filter((connection) => { return connection.output.stepId === this.stepId; }); } hasConnectedOutputTerminals() { // Does this step/node have connected connections return this._getOutputConnections().length > 0; } hasMappedOverInputTerminals() { return Boolean(this.stores.stepStore.stepMapOver[this.stepId]?.collectionType); } resetMappingIfNeeded(connection?: Connection) { const mapOver = this.mapOver; if (!mapOver.isCollection) { return; } // No output terminals are counting on this being mapped // over if connected inputs are still mapped over or if none // of the outputs are connected... const reset = !this.hasConnectedOutputTerminals(); if (reset) { this.resetMapping(connection); } } } class BaseInputTerminal extends Terminal { datatypes: InputTerminalInputs["datatypes"]; optional: InputTerminalInputs["optional"]; localMapOver: CollectionTypeDescriptor; constructor(attr: InputTerminalArgs) { super(attr); this.datatypes = attr.input.datatypes; this.multiple = attr.input.multiple; this.optional = attr.input.optional; if ( this.stores.stepStore.stepInputMapOver[this.stepId] && this.stores.stepStore.stepInputMapOver[this.stepId]?.[this.name] ) { this.localMapOver = this.stores.stepStore.stepInputMapOver[this.stepId]![this.name]!; } else { this.localMapOver = NULL_COLLECTION_TYPE_DESCRIPTION; } } connect(other: BaseOutputTerminal): void { super.connect(other); this.setDefaultMapOver(other); } setDefaultMapOver(other: BaseOutputTerminal) { const otherCollectionType = this._otherCollectionType(other); if (otherCollectionType.isCollection) { return this.setMapOver(otherCollectionType); } } getStepMapOver() { this.getConnectedTerminals().forEach((other) => { this.setDefaultMapOver(other); }); } canAccept(outputTerminal: BaseOutputTerminal) { if (this.stepId == outputTerminal.stepId) { return new ConnectionAcceptable(false, "Cannot connect output to input of same step."); } if (this._inputFilled()) { return new ConnectionAcceptable( false, "Input already filled with another connection, delete it before connecting another output.", ); } else { return this.attachable(outputTerminal); } } attachable(_terminal: BaseOutputTerminal): ConnectionAcceptable { // TODO: provide through Mixin throw Error("Subclass needs to implement this"); } _getOutputStepsMapOver() { const connections = this._getOutputConnections(); const connectedStepIds = Array.from(new Set(connections.map((connection) => connection.output.stepId))); return connectedStepIds.map( (stepId) => this.stores.stepStore.stepMapOver[stepId] || NULL_COLLECTION_TYPE_DESCRIPTION, ); } resetMapping(connection?: Connection) { super.resetMapping(connection); this.stores.stepStore.changeStepInputMapOver(this.stepId, this.name, NULL_COLLECTION_TYPE_DESCRIPTION); const outputStepIds = this._getOutputTerminals().map((outputTerminal) => outputTerminal.stepId); if (connection) { outputStepIds.push(connection.output.stepId); } Array.from(new Set(outputStepIds)).forEach((stepId) => { const step = this.stores.stepStore.getStep(stepId); if (step) { // step must have an output, since it is or was connected to this step const terminalSource = step.outputs[0]; if (terminalSource) { const terminal = terminalFactory(step.id, terminalSource, this.datatypesMapper, this.stores); // drop mapping restrictions terminal.resetMappingIfNeeded(); // re-establish map over through inputs step.inputs.forEach((input) => { terminalFactory(step.id, input, this.datatypesMapper, this.stores).getStepMapOver(); }); } } else { console.error(`Invalid step. Could not fine step with id ${stepId} in store.`); } }); } _getOutputTerminals() { return this.stores.connectionStore.getOutputTerminalsForInputTerminal(this.id); } _getFirstOutputTerminal() { const outputTerminals = this._getOutputTerminals(); if (outputTerminals.length > 0) { return outputTerminals[0]; } return null; } isMappedOver(): boolean { return Boolean(this.localMapOver.isCollection); } connected() { return Boolean(this._getFirstOutputTerminal()); } _inputFilled() { let inputFilled: boolean; if (!this.connected()) { inputFilled = false; } else { if (this.multiple) { // Can only attach one collection to multiple input // data parameter. inputFilled = !!this._collectionAttached(); } else { inputFilled = true; } } return inputFilled; } _collectionAttached() { const outputTerminals = this._getOutputTerminals(); return outputTerminals.some((outputTerminal) => { const step = this.stores.stepStore.getStep(outputTerminal.stepId); if (!step) { console.error(`Invalid step. Could not find step with id ${outputTerminal.stepId} in store.`); return false; } const output = step.outputs.find((output) => output.name == outputTerminal.name); if ( output && (("collection" in output && output.collection) || this.stores.stepStore.stepMapOver[outputTerminal.stepId]?.isCollection || ("extensions" in output && output.extensions.indexOf("input") > 0)) ) { return true; } }); } _mappingConstraints() { // If this is a connected terminal, return list of collection types // other terminals connected to node are constraining mapping to. if (this.mapOver.isCollection) { return [this.mapOver]; } return this._getOutputStepsMapOver(); } _producesAcceptableDatatype(other: BaseOutputTerminal) { // other is a non-collection output... if (other instanceof OutputParameterTerminal) { return new ConnectionAcceptable(false, "Cannot connect workflow parameter to data input."); } return producesAcceptableDatatype(this.datatypesMapper, this.datatypes, other.datatypes); } _producesAcceptableDatatypeAndOptionalness(other: BaseOutputTerminal) { if (!this.optional && !this.multiple && other.optional) { return new ConnectionAcceptable(false, "Cannot connect an optional output to a non-optional input"); } return this._producesAcceptableDatatype(other); } _otherCollectionType(other: BaseOutputTerminal) { let otherCollectionType = NULL_COLLECTION_TYPE_DESCRIPTION; if (other.isCollection && other.collectionType) { otherCollectionType = other.collectionType; } const otherMapOver = other.mapOver; if (otherMapOver.isCollection) { otherCollectionType = otherMapOver.append(otherCollectionType); } return otherCollectionType; } getConnectedTerminals() { return this.connections.map((connection) => { const outputStep = this.stores.stepStore.getStep(connection.output.stepId); if (!outputStep) { return new InvalidOutputTerminal({ stepId: -1, optional: false, datatypes: [], name: connection.output.name, valid: false, datatypesMapper: this.datatypesMapper, stores: this.stores, }); } let terminalSource = outputStep.outputs.find((output) => output.name === connection.output.name); if (!terminalSource) { return new InvalidOutputTerminal({ stepId: -1, optional: false, datatypes: [], name: connection.output.name, valid: false, datatypesMapper: this.datatypesMapper, stores: this.stores, }); } const postJobActionKey = `ChangeDatatypeAction${connection.output.name}`; if ( "extensions" in terminalSource && outputStep.post_job_actions && postJobActionKey in outputStep.post_job_actions ) { const extensionType = outputStep.post_job_actions![postJobActionKey]!.action_arguments.newtype; (terminalSource as DataOutput | CollectionOutput) = { ...terminalSource, extensions: extensionType ? [extensionType] : [], }; } return terminalFactory(outputStep.id, terminalSource, this.datatypesMapper, this.stores); }); } getInvalidConnectedTerminals() { return this.getConnectedTerminals().filter((terminal) => { const canAccept = this.attachable(terminal); const connectionId: ConnectionId = `${this.stepId}-${this.name}-${terminal.stepId}-${terminal.name}`; if (!canAccept.canAccept) { this.stores.connectionStore.markInvalidConnection(connectionId, canAccept.reason ?? "Unknown"); return true; } else if (this.stores.connectionStore.invalidConnections[connectionId]) { this.stores.connectionStore.dropFromInvalidConnections(connectionId); } return false; }); } destroyInvalidConnections() { this.getInvalidConnectedTerminals().forEach((terminal) => this.disconnect(terminal)); } } interface InvalidInputTerminalArgs extends InputTerminalArgs { valid: false; } export class InvalidInputTerminal extends BaseInputTerminal { valid: false; localMapOver: CollectionTypeDescriptor; constructor(attr: InvalidInputTerminalArgs) { super(attr); this.valid = false; this.localMapOver = NULL_COLLECTION_TYPE_DESCRIPTION; } attachable(_terminal: BaseOutputTerminal) { return new ConnectionAcceptable(false, "Cannot attach to invalid input. Disconnect this input."); } } // TODO: turn into InputTerminalAttachableMixin ? export class InputTerminal extends BaseInputTerminal { collection: boolean; constructor(attr: InputTerminalArgs) { super(attr); this.collection = false; this.getStepMapOver(); } attachable(other: BaseOutputTerminal) { const otherCollectionType = this._otherCollectionType(other); const mapOver = this.mapOver; if (otherCollectionType.isCollection) { if (this.multiple) { if (this.connected() && !this._collectionAttached()) { return new ConnectionAcceptable( false, "Cannot attach collections to data parameters with individual data inputs already attached.", ); } if (otherCollectionType.collectionType && otherCollectionType.collectionType.endsWith("paired")) { return new ConnectionAcceptable( false, "Cannot attach paired inputs to multiple data parameters, only lists may be treated this way.", ); } } if (mapOver.isCollection && mapOver.canMatch(otherCollectionType)) { return this._producesAcceptableDatatypeAndOptionalness(other); } else if ( this.multiple && new CollectionTypeDescription("list").append(this.mapOver).canMatch(otherCollectionType) ) { // This handles the special case of a list input being connected to a multiple="true" data input. // Nested lists would be correctly mapped over by the above condition. return this._producesAcceptableDatatypeAndOptionalness(other); } else { // Need to check if this would break constraints... const mappingConstraints = this._mappingConstraints(); if (mappingConstraints.every(otherCollectionType.canMatch.bind(otherCollectionType))) { return this._producesAcceptableDatatypeAndOptionalness(other); } else { if (mapOver.isCollection) { // incompatible collection type attached if (this.hasConnectedMappedInputTerminals()) { return new ConnectionAcceptable( false, "Can't map over this input with output collection type - other inputs have an incompatible map over collection type. Disconnect inputs (and potentially outputs) and retry.", ); } else { return new ConnectionAcceptable( false, "Can't map over this input with output collection type - this step has outputs defined constraining the mapping of this tool. Disconnect outputs and retry.", ); } } else { return new ConnectionAcceptable( false, "Can't map over this input with output collection type - an output of this tool is mapped over constraining this input. Disconnect output(s) and retry.", ); } } } } else { if (this.localMapOver.isCollection) { return new ConnectionAcceptable( false, "Cannot attach non-collection output to mapped over input, consider disconnecting inputs and outputs to reset this input's mapping.", ); } } return this._producesAcceptableDatatypeAndOptionalness(other); } } interface InputParameterTerminalArgs extends InputTerminalArgs { type: ParameterStepInput["type"]; } export class InputParameterTerminal extends BaseInputTerminal { type: ParameterStepInput["type"]; constructor(attr: InputParameterTerminalArgs) { super(attr); this.type = attr.type; this.getStepMapOver(); } effectiveType(parameterType: string) { let newType: string; switch (parameterType) { case "select": newType = "text"; break; case "data_column": newType = "integer"; break; default: newType = parameterType; } return newType; } attachable(other: BaseOutputTerminal) { const effectiveThisType = this.effectiveType(this.type); const otherType = ("type" in other && other.type) || "data"; const effectiveOtherType = this.effectiveType(otherType); if (!this.optional && other.optional) { return new ConnectionAcceptable(false, `Cannot attach an optional output to a required parameter`); } const canAccept = effectiveThisType === effectiveOtherType; if (!this.multiple && other.multiple) { return new ConnectionAcceptable( false, `This output parameter represents multiple values but input only accepts a single value`, ); } return new ConnectionAcceptable( canAccept, canAccept ? null : `Cannot attach a ${effectiveOtherType} parameter to a ${effectiveThisType} input`, ); } } interface InputCollectionTerminalArgs extends InputTerminalArgs { collection_types: string[] | null; } export class InputCollectionTerminal extends BaseInputTerminal { collection: boolean; collectionTypes: CollectionTypeDescriptor[]; constructor(attr: InputCollectionTerminalArgs) { super(attr); this.multiple = false; this.collection = true; this.collectionTypes = attr.collection_types ? attr.collection_types.map((collectionType) => new CollectionTypeDescription(collectionType)) : []; if (!this.collectionTypes.length) { this.collectionTypes.push(ANY_COLLECTION_TYPE_DESCRIPTION); } this.getStepMapOver(); } _effectiveMapOver(otherCollectionType: CollectionTypeDescriptor) { const collectionTypes = this.collectionTypes; const canMatch = collectionTypes.some((collectionType) => collectionType.canMatch(otherCollectionType)); if (!canMatch) { for (const collectionTypeIndex in collectionTypes) { const collectionType = collectionTypes[collectionTypeIndex]!; if (otherCollectionType.canMapOver(collectionType)) { const effectiveMapOver = otherCollectionType.effectiveMapOver(collectionType); if (effectiveMapOver != NULL_COLLECTION_TYPE_DESCRIPTION) { return effectiveMapOver; } } } } return NULL_COLLECTION_TYPE_DESCRIPTION; } _effectiveCollectionTypes() { return this.collectionTypes.map((t) => this.localMapOver.append(t)); } attachable(other: BaseOutputTerminal) { const otherCollectionType = this._otherCollectionType(other); if (otherCollectionType.isCollection) { const effectiveCollectionTypes = this._effectiveCollectionTypes(); const mapOver = this.mapOver; const canMatch = effectiveCollectionTypes.some((effectiveCollectionType, i) => { if (!effectiveCollectionType.canMatch(otherCollectionType)) { return false; } // sample_sheet asymmetry: sample_sheet input requires sample_sheet output, // but sample_sheet output can satisfy list input. const rawInputType = this.collectionTypes[i]?.collectionType; if ( rawInputType?.startsWith("sample_sheet") && !otherCollectionType.collectionType?.startsWith("sample_sheet") ) { return false; } return true; }); if (canMatch) { // Only way a direct match... return this._producesAcceptableDatatypeAndOptionalness(other); // Otherwise we need to mapOver } else if (mapOver.isCollection) { // In this case, mapOver already set and we didn't match skipping... if (this.hasConnectedMappedInputTerminals()) { return new ConnectionAcceptable( false, "Can't map over this input with output collection type - other inputs have an incompatible map over collection type. Disconnect inputs (and potentially outputs) and retry.", ); } else { return new ConnectionAcceptable( false, "Can't map over this input with output collection type - this step has outputs defined constraining the mapping of this tool. Disconnect outputs and retry.", ); } } else if ( this.collectionTypes.some((collectionType) => { if (!otherCollectionType.canMapOver(collectionType)) { return false; } // sample_sheet asymmetry: same as canMatch guard above if ( collectionType.collectionType?.startsWith("sample_sheet") && !otherCollectionType.collectionType?.startsWith("sample_sheet") ) { return false; } return true; }) ) { // we're not mapped over - but hey maybe we could be... lets check. const effectiveMapOver = this._effectiveMapOver(otherCollectionType); // Need to check if this would break constraints... const mappingConstraints = this._mappingConstraints(); if (mappingConstraints.every((d) => effectiveMapOver.canMatch(d))) { return this._producesAcceptableDatatypeAndOptionalness(other); } else { return new ConnectionAcceptable( false, "Can't map over this input with output collection type - this step has outputs defined constraining the mapping of this tool. Disconnect outputs and retry.", ); } } else { let reason = "Incompatible collection type(s) for attachment."; if (this.collectionTypes.length == 1) { if ( otherCollectionType.collectionType?.endsWith("paired_or_unpaired") && this.collectionTypes[0]?.collectionType?.endsWith("paired") ) { reason = "Cannot attach optionally paired outputs to inputs requiring pairing, consider using the 'Split Paired and Unpaired' tool to extract just the pairs out from this output."; } } return new ConnectionAcceptable(false, reason); } } else { return new ConnectionAcceptable(false, "Cannot attach a data output to a collection input."); } } } interface BaseOutputTerminalArgs extends BaseTerminalArgs { datatypes: string[]; optional: boolean; } export type InputTerminalsAndInvalid = InputTerminals | InvalidInputTerminal; class BaseOutputTerminal extends Terminal { datatypes: BaseOutputTerminalArgs["datatypes"]; optional: BaseOutputTerminalArgs["optional"]; isCollection?: boolean; collectionType?: CollectionTypeDescriptor; type?: string; constructor(attr: BaseOutputTerminalArgs) { super(attr); this.datatypes = attr.datatypes; this.optional = attr.optional || Boolean(this.stores.stepStore.getStep(this.stepId)?.when); this.terminalType = "output"; } getConnectedTerminals(): InputTerminalsAndInvalid[] { return this.connections.map((connection) => { const inputStep = this.stores.stepStore.getStep(connection.input.stepId); assertDefined(inputStep, `Invalid step. Could not find step with id ${connection.input.stepId} in store.`); const allInputs = getCombinedStepInputs(inputStep, this.stores.stepStore); const terminalSource = allInputs.find((input) => input.name === connection.input.name); if (!terminalSource) { return new InvalidInputTerminal({ valid: false, name: connection.input.name, stepId: connection.input.stepId, datatypesMapper: this.datatypesMapper, input_type: "dataset", input: { datatypes: [], optional: false, multiple: false, }, stores: this.stores, }); } return terminalFactory(inputStep.id, terminalSource, this.datatypesMapper, this.stores); }); } getInvalidConnectedTerminals() { return this.getConnectedTerminals().filter((terminal: any) => { const canAccept = terminal.attachable(this); const connectionId: ConnectionId = `${terminal.stepId}-${terminal.name}-${this.stepId}-${this.name}`; if (!canAccept.canAccept) { this.stores.connectionStore.markInvalidConnection(connectionId, canAccept.reason ?? "Unknown"); return true; } else if (this.stores.connectionStore.invalidConnections[connectionId]) { this.stores.connectionStore.dropFromInvalidConnections(connectionId); } return false; }); } destroyInvalidConnections() { this.getConnectedTerminals().forEach((terminal) => { if (!terminal.attachable(this).canAccept) { terminal.disconnect(this); } }); } validInputTerminals() { const validInputTerminals: InputTerminals[] = []; Object.values(this.stores.stepStore.steps).map((step) => { step.inputs?.forEach((input) => { const inputTerminal = terminalFactory(step.id, input, this.datatypesMapper, this.stores); if (inputTerminal.canAccept(this).canAccept) { validInputTerminals.push(inputTerminal); } }); }); return validInputTerminals; } } export class OutputTerminal extends BaseOutputTerminal {} interface OutputCollectionTerminalArgs extends BaseOutputTerminalArgs { collection_type: string; collection_type_source: string | null; } export class OutputCollectionTerminal extends BaseOutputTerminal { collectionTypeSource: string | null; constructor(attr: OutputCollectionTerminalArgs) { super(attr); this.collectionTypeSource = null; if (attr.collection_type) { this.collectionType = new CollectionTypeDescription(attr.collection_type); } else { this.collectionTypeSource = attr.collection_type_source; if (!this.collectionTypeSource) { console.debug(NO_COLLECTION_TYPE_INFORMATION_MESSAGE); } this.collectionType = this.getCollectionTypeFromInput() || ANY_COLLECTION_TYPE_DESCRIPTION; } this.isCollection = true; } getCollectionTypeFromInput() { const connection = this.stores.connectionStore.connections.find( (connection) => connection.input.name === this.collectionTypeSource && connection.input.stepId === this.stepId, ); if (connection) { const outputStep = this.stores.stepStore.getStep(connection.output.stepId); const inputStep = this.stores.stepStore.getStep(this.stepId); assertDefined(inputStep, `Invalid step. Could not find step with id ${connection.input.stepId} in store.`); if (outputStep) { const stepOutput = outputStep.outputs.find((output) => output.name == connection.output.name); const stepInput = inputStep.inputs.find((input) => input.name === this.collectionTypeSource); if (stepInput && stepOutput) { const outputTerminal = terminalFactory( connection.output.stepId, stepOutput, this.datatypesMapper, this.stores, ); const inputTerminal = terminalFactory( connection.output.stepId, stepInput, this.datatypesMapper, this.stores, ); // otherCollectionType is the mapped over output collection as it would appear at the input terminal const otherCollectionType = inputTerminal._otherCollectionType(outputTerminal); // we need to find which of the possible input collection types is connected if ("collectionTypes" in inputTerminal) { // collection_type_source must point at input collection terminal const connectedCollectionType = inputTerminal.collectionTypes.find( (collectionType) => otherCollectionType.canMatch(collectionType) || otherCollectionType.canMapOver(collectionType), ); if (connectedCollectionType) { if (connectedCollectionType.collectionType === "any") { // if the input collection type is "any" this output's collection type // is exactly the same as the connected output return otherCollectionType; } else { // else we pick the matching input collection type // so that the map over logic applies correctly return connectedCollectionType; } } } } } } return ANY_COLLECTION_TYPE_DESCRIPTION; } } interface OutputParameterTerminalArgs extends Omit { type: ParameterOutput["type"]; multiple: ParameterOutput["multiple"]; } export class OutputParameterTerminal extends BaseOutputTerminal { constructor(attr: OutputParameterTerminalArgs) { super({ ...attr, datatypes: [] }); this.type = attr.type; this.multiple = attr.multiple; } } interface InvalidOutputTerminalArgs extends BaseOutputTerminalArgs { valid: false; } export class InvalidOutputTerminal extends BaseOutputTerminal { valid: false; constructor(attr: InvalidOutputTerminalArgs) { super(attr); this.valid = false; } validInputTerminals() { return []; } canAccept() { return new ConnectionAcceptable(false, "Can't connect to invalid terminal."); } } export type OutputTerminals = OutputTerminal | OutputCollectionTerminal | OutputParameterTerminal; export type InputTerminals = InputTerminal | InputCollectionTerminal | InputParameterTerminal; export function producesAcceptableDatatype( datatypesMapper: DatatypesMapperModel, inputDatatypes: string[], otherDatatypes: string[], ) { for (const t in inputDatatypes) { const thisDatatype = inputDatatypes[t]!; if (thisDatatype === "input") { return new ConnectionAcceptable(true, null); } // FIXME: No idea what to do about case when datatype is 'input' const validMatch = otherDatatypes.some( (otherDatatype) => otherDatatype === "input" || otherDatatype === "_sniff_" || datatypesMapper.isSubType(otherDatatype, thisDatatype), ); if (validMatch) { return new ConnectionAcceptable(true, null); } } const datatypesSet = new Set(datatypesMapper.datatypes); const invalidDatatypes = otherDatatypes.filter((datatype) => !datatypesSet.has(datatype)); if (invalidDatatypes.length) { return new ConnectionAcceptable( false, `Effective output data type(s) [${invalidDatatypes.join( ", ", )}] unknown. This tool cannot be run on this Galaxy Server at this moment, please contact the Administrator.`, ); } return new ConnectionAcceptable( false, `Effective output data type(s) [${otherDatatypes.join( ", ", )}] do not appear to match input type(s) [${inputDatatypes.join(", ")}].`, ); } type TerminalSourceAndInvalid = TerminalSource | InvalidOutputTerminalArgs | InvalidInputTerminalArgs; function isInvalidOutputArg(arg: TerminalSourceAndInvalid): arg is InvalidOutputTerminalArgs { return "name" in arg && "valid" in arg && arg.valid === false; } function isOutputParameterArg(arg: TerminalSourceAndInvalid): arg is ParameterOutput { return "name" in arg && "parameter" in arg && arg.parameter === true; } function isOutputCollectionArg(arg: TerminalSourceAndInvalid): arg is CollectionOutput { return "name" in arg && "collection" in arg && arg.collection; } function isOutputArg(arg: TerminalSourceAndInvalid): arg is DataOutput { return "name" in arg && "extensions" in arg; } type TerminalOf = T extends InvalidInputTerminalArgs ? InvalidInputTerminal : T extends DataStepInput ? InputTerminal : T extends DataCollectionStepInput ? InputCollectionTerminal : T extends ParameterStepInput ? InputParameterTerminal : T extends DataOutput ? OutputTerminal : T extends CollectionOutput ? OutputCollectionTerminal : T extends ParameterOutput ? OutputParameterTerminal : T extends BaseOutputTerminalArgs ? InvalidOutputTerminal : never; export function terminalFactory( stepId: number, terminalSource: T, datatypesMapper: DatatypesMapperModel, stores: ReturnType, ): TerminalOf { if ("input_type" in terminalSource) { const terminalArgs = { datatypesMapper: datatypesMapper, input_type: terminalSource.input_type, name: terminalSource.name, stepId: stepId, stores, }; if ("valid" in terminalSource) { return new InvalidInputTerminal({ ...terminalArgs, input: { datatypes: [], multiple: false, optional: false, }, valid: terminalSource.valid as false, }) as TerminalOf; } else { const inputArgs = { datatypes: terminalSource.extensions, multiple: terminalSource.multiple, optional: terminalSource.optional, }; if (terminalSource.input_type == "dataset") { // type cast appears to be necessary: https://github.com/Microsoft/TypeScript/issues/13995 return new InputTerminal({ ...terminalArgs, input: inputArgs, }) as TerminalOf; } else if (terminalSource.input_type === "dataset_collection") { return new InputCollectionTerminal({ ...terminalArgs, collection_types: terminalSource.collection_types, input: { ...inputArgs, }, }) as TerminalOf; } else if (terminalSource.input_type === "parameter") { return new InputParameterTerminal({ ...terminalArgs, type: terminalSource.type, input: { ...inputArgs, }, }) as TerminalOf; } } } else if (terminalSource.name) { const outputArgs = { name: terminalSource.name, optional: terminalSource.optional, stepId: stepId, datatypesMapper: datatypesMapper, stores, }; if (isOutputParameterArg(terminalSource)) { return new OutputParameterTerminal({ ...outputArgs, multiple: terminalSource.multiple, type: terminalSource.type, }) as TerminalOf; } else if (isOutputCollectionArg(terminalSource)) { return new OutputCollectionTerminal({ ...outputArgs, datatypes: terminalSource.extensions, collection_type: terminalSource.collection_type, collection_type_source: terminalSource.collection_type_source, }) as TerminalOf; } else if (isOutputArg(terminalSource)) { return new OutputTerminal({ ...outputArgs, datatypes: terminalSource.extensions, }) as TerminalOf; } } if (isInvalidOutputArg(terminalSource)) { return new InvalidOutputTerminal(terminalSource) as TerminalOf; } throw Error(`Could not build terminal for ${terminalSource}`); }