Dashboard

Component Workflow Editor Terminals

Terminal connection logic, compatibility checking, map-over propagation, plain class instances

Raw
Revised:
2026-05-16
Revision:
6
Related Notes:
Component - Collection Tool Execution Semantics, Component - Invocation Graph View, Component - Workflow Editor Terminal Tests, PR 20390 - Workflow Graph Search, PR 21932 - History Graph API, PR 21935 - Workflow Extraction Vue Conversion, PR 22706 - Workflow Extraction by IDs

Workflow Editor Terminals Module - Architecture Research Report

Overview

The terminals module (client/src/components/Workflow/Editor/modules/terminals.ts) is the core connection logic engine for the Galaxy workflow editor. It models the typed endpoints (inputs and outputs) of workflow steps as class instances that encapsulate:

  • Connection compatibility checking (datatype matching, collection mapping)
  • Connection lifecycle management (connect, disconnect, undo/redo)
  • Collection “map over” state propagation across the workflow graph
  • Invalid connection detection and cleanup

The module is not reactive — terminal objects are plain class instances rebuilt by Vue watchers whenever upstream state changes. This is the deliberate architecture: the Pinia stores are the source of truth, and terminals are ephemeral logic objects constructed on demand.


File Map

FileRole
client/src/components/Workflow/Editor/modules/terminals.tsTerminal classes + factory
client/src/components/Workflow/Editor/modules/collectionTypeDescription.tsCollection type algebra
client/src/stores/workflowStepStore.tsStep data, map-over state, terminal source types
client/src/stores/workflowConnectionStore.tsConnection list, terminal-to-connection indexes
client/src/stores/workflowStoreTypes.tsConnection/Terminal plain-data interfaces
client/src/stores/workflowEditorStateStore.tsUI state: terminal positions, dragging terminal
client/src/composables/workflowStores.tsStore bundle, DI via Vue provide/inject
client/src/components/Workflow/Editor/composables/useTerminal.tsVue composable: terminal construction + reactivity bridge
client/src/components/Workflow/Editor/NodeInput.vueInput terminal Vue component
client/src/components/Workflow/Editor/NodeOutput.vueOutput terminal Vue component
client/src/components/Workflow/Editor/ConnectionMenu.vueKeyboard-accessible connection picker
client/src/components/Workflow/Editor/WorkflowEdges.vueSVG edge rendering, uses OutputTerminals type
client/src/components/Workflow/Editor/WorkflowGraph.vueTop-level graph, passes OutputTerminals for dragging
client/src/components/Workflow/Editor/modules/linting.tsWorkflow linter, uses terminalFactory
client/src/components/Datatypes/model.tsDatatypesMapperModel for subtype checking
client/src/stores/undoRedoStore/index.tsUndo/redo action stack
client/src/components/Workflow/Editor/modules/terminals.test.tsComprehensive unit tests

Class Hierarchy

EventEmitter (from "events"; browser-polyfilled in Vite)
  |
  Terminal                          (base class; 194 lines)
  |-- BaseInputTerminal             (shared input logic; lines 196-430)
  |   |-- InvalidInputTerminal      (placeholder for broken inputs)
  |   |-- InputTerminal             (dataset inputs, multiple=true support)
  |   |-- InputParameterTerminal    (parameter inputs: text, integer, etc.)
  |   |-- InputCollectionTerminal   (dataset_collection inputs)
  |
  |-- BaseOutputTerminal            (shared output logic; lines 680-754)
      |-- OutputTerminal            (plain dataset outputs; empty subclass)
      |-- OutputCollectionTerminal  (collection outputs with collection_type/collection_type_source)
      |-- OutputParameterTerminal   (parameter outputs: text, integer, etc.)
      |-- InvalidOutputTerminal     (placeholder for broken outputs)

Type Aliases

type OutputTerminals = OutputTerminal | OutputCollectionTerminal | OutputParameterTerminal;
type InputTerminals  = InputTerminal | InputCollectionTerminal | InputParameterTerminal;
type InputTerminalsAndInvalid = InputTerminals | InvalidInputTerminal;

Terminal Base Class

Terminal extends EventEmitter from the "events" package (browser-polyfilled by Vite; not actual Node.js at runtime). Event emission is not actively used in the current codebase — it’s legacy scaffolding. Key properties:

PropertySourcePurpose
storesConstructor argBundle of Pinia stores (connection, step, undoRedo, etc.)
stepIdConstructor argWhich workflow step this terminal belongs to
nameConstructor argThe input/output name within the step
terminalType"input" or "output"Discriminator for ID generation
datatypesMapperConstructor argGalaxy datatype hierarchy for subtype checks
multipleDefault falseWhether input accepts multiple connections
localMapOverInitially NULL_COLLECTION_TYPE_DESCRIPTIONPer-input map-over tracking

ID Scheme

Terminal IDs follow the pattern node-{stepId}-{input|output}-{name} and are used as keys in the connection store’s lookup indexes.

Connection Accessors

get id()          // "node-{stepId}-{terminalType}-{name}"
get connections() // delegates to connectionStore.getConnectionsForTerminal(this.id)
get mapOver()     // delegates to stepStore.stepMapOver[this.stepId]

Connection Lifecycle

Connect

Terminal.connect(other)
  -> undoRedoStore.action()
       .onRun(makeConnection)     // adds to connectionStore
       .onUndo(dropConnection)    // removes from connectionStore
       .apply()

BaseInputTerminal.connect() additionally calls setDefaultMapOver(other) after the undo/redo action to propagate collection mapping.

Disconnect

Terminal.disconnect(other)
  -> undoRedoStore.action()
       .onRun(dropConnection)
       .onUndo(makeConnection)
       .apply()

dropConnection also calls resetMappingIfNeeded(connection) which clears the step’s map-over state if no output terminals are still relying on it.

Connection Building

buildConnection(other) normalizes either a Terminal instance or a raw Connection object into a Connection shape:

{
  input:  { stepId, name, connectorType: "input" },
  output: { stepId, name, connectorType: "output" }
}

Input Terminal Logic

BaseInputTerminal

Adds input-specific state:

  • datatypes: string[] — accepted file extensions
  • optional: boolean — whether the input is required
  • localMapOver — restored from stepStore.stepInputMapOver[stepId][name] on construction

Key methods:

MethodPurpose
canAccept(output)Pre-flight check: same step? input filled? Then delegates to attachable()
attachable(output)Subclass-specific compatibility logic (abstract in base)
getStepMapOver()Iterates connected output terminals and calls setDefaultMapOver for each
_inputFilled()Checks if input already has a connection (respects multiple)
_collectionAttached()Checks if any connected output is a collection
_otherCollectionType(other)Computes the effective collection type of an output, including its step’s map-over
_producesAcceptableDatatype(other)Datatype compatibility check via DatatypesMapperModel.isSubType
_producesAcceptableDatatypeAndOptionalness(other)Datatype + optional-to-required check
_mappingConstraints()Returns collection types constraining this terminal’s mapping
getConnectedTerminals()Builds output terminal objects for all connections to this input
getInvalidConnectedTerminals()Filters connected terminals through attachable(), marks invalid ones
destroyInvalidConnections()Disconnects all invalid connections
resetMapping(connection?)Clears map-over state and propagates reset to connected output steps

InputTerminal (dataset inputs)

The attachable() method implements the most complex connection logic:

  1. Computes otherCollectionType from the output.
  2. If the output is a collection:
    • multiple input + already has non-collection: reject (“Cannot attach collections to data parameters with individual data inputs already attached.”)
    • multiple input + paired-ending collection: reject (“Cannot attach paired inputs to multiple data parameters, only lists may be treated this way.”)
    • mapOver matches: accept (datatype check only)
    • multiple + list prefix match: accept (special case for list input -> multiple data)
    • mapping constraints all match: accept
    • otherwise: reject with contextual message about which constraints conflict
  3. If the output is NOT a collection:
    • localMapOver is a collection: reject (“Cannot attach non-collection output to mapped over input”)
  4. Falls through to datatype + optionalness check.

InputCollectionTerminal (dataset_collection inputs)

Additional properties:

  • collectionTypes: CollectionTypeDescriptor[] — the accepted collection types (e.g., ["list", "paired"])
  • If no collection types specified, defaults to [ANY_COLLECTION_TYPE_DESCRIPTION]

Overrides _effectiveMapOver() to compute what the map-over would be if the terminal’s accepted collection types don’t directly match the output. For instance, connecting a list:paired output to a paired input produces a list map-over.

The attachable() method:

  1. Computes effective collection types (each collectionType prepended with localMapOver)
  2. If any effective type matches, accept
  3. If already mapped over with incompatible type, reject
  4. If could be mapped over (canMapOver), check mapping constraints
  5. Special error for paired_or_unpaired -> paired mismatch

InputParameterTerminal (parameter inputs)

Simpler logic:

  • Normalizes parameter type names (select -> text, data_column -> integer)
  • Checks type equality between input and output
  • Rejects optional output -> required input
  • Rejects multiple output -> single input

Output Terminal Logic

BaseOutputTerminal

Properties:

  • datatypes: string[]
  • optional: boolean — also set to true if step has a when clause (conditional step)
  • isCollection?: boolean
  • collectionType?: CollectionTypeDescriptor
  • type?: string (parameter type)

Key methods:

MethodPurpose
getConnectedTerminals()Builds input terminal objects for all connections from this output
getInvalidConnectedTerminals()Filters via each input’s attachable()
destroyInvalidConnections()Disconnects invalid connected input terminals
validInputTerminals()Scans all steps’ inputs for any that canAccept(this) — used by ConnectionMenu

OutputTerminal

Empty subclass of BaseOutputTerminal. Used for plain dataset outputs.

OutputCollectionTerminal

Adds:

  • collectionTypeSource: string | null — when collection_type is not static, this names the input whose connected collection type determines this output’s type
  • collectionType — either a CollectionTypeDescription from collection_type, or resolved dynamically via getCollectionTypeFromInput()
  • isCollection = true

getCollectionTypeFromInput() is the dynamic collection type resolution logic:

  1. Finds the connection to the input named by collectionTypeSource
  2. Looks up the connected output step and its output terminal
  3. Builds terminal objects for both sides
  4. Computes _otherCollectionType to determine the effective type
  5. Matches against the input’s collectionTypes array
  6. Returns the matching type (or ANY_COLLECTION_TYPE_DESCRIPTION if any)

This mechanism supports tools like filter_failed where the output collection type mirrors whatever was connected to a particular input.

OutputParameterTerminal

Adds type and multiple from the parameter output definition. Passes empty datatypes array to base.

InvalidOutputTerminal / InvalidInputTerminal

Sentinel classes for broken connections. attachable() always returns ConnectionAcceptable(false, ...). Used when a connected step or terminal source can’t be found (e.g., after step deletion before cleanup).


Collection Type Description System

File: client/src/components/Workflow/Editor/modules/collectionTypeDescription.ts

CollectionTypeDescriptor Interface

interface CollectionTypeDescriptor {
    isCollection: boolean;
    collectionType: string | null;
    rank: number;                    // depth of nesting (e.g., "list:paired" = 2)
    canMatch(other): boolean;        // exact type match (with paired_or_unpaired flexibility)
    canMapOver(other): boolean;      // can this type be "mapped over" to produce `other`
    append(other): CollectionTypeDescriptor;  // nest types (e.g., "list".append("paired") = "list:paired")
    equal(other): boolean;
    effectiveMapOver(other): CollectionTypeDescriptor;  // compute the leftover after mapping
}

Singleton Descriptors

NameisCollectioncollectionTypeBehavior
NULL_COLLECTION_TYPE_DESCRIPTIONfalsenullNon-collection. canMatch always false. append returns other.
ANY_COLLECTION_TYPE_DESCRIPTIONtrue"any"Matches any collection. canMapOver always false. append returns self.

CollectionTypeDescription Class

The main implementation. Collection types are colon-separated strings like "list", "paired", "list:paired", "list:list", etc.

Key algorithms:

canMatch: Checks if two collection types are compatible. Special cases:

  • paired matches paired_or_unpaired
  • X:paired_or_unpaired matches X, X:paired
  • Otherwise strict string equality

canMapOver: Determines if this can be decomposed to produce other as inner elements. E.g., list:paired canMapOver paired because stripping the outer list yields paired. paired_or_unpaired has special handling: anything can be mapped over it since it can always act as a single dataset.

effectiveMapOver: Computes the “leftover” collection nesting after consuming the inner type. E.g., list:list.effectiveMapOver(list) = list. Complex handling for paired_or_unpaired suffixes.

append: Creates nested types: list.append(paired) = list:paired.


Map-Over State Management

Map-over is Galaxy’s mechanism for running a step once per element in a collection. The state is tracked in two places in workflowStepStore:

stepMapOver: { [stepId: number]: CollectionTypeDescriptor }
stepInputMapOver: { [stepId: number]: { [inputName: string]: CollectionTypeDescriptor } }

Flow

  1. When an input terminal connects to a collection output, BaseInputTerminal.setDefaultMapOver() calls this.setMapOver(otherCollectionType).
  2. Terminal.setMapOver():
    • Adjusts for multiple inputs (subtracts a list level)
    • Calls _effectiveMapOver() (overridden in InputCollectionTerminal to account for accepted collection types)
    • Updates stepStore.changeStepInputMapOver(stepId, name, effectiveMapOver) for the per-input tracking
    • Updates stepStore.changeStepMapOver(stepId, effectiveMapOver) for the step-level tracking
  3. On disconnect, resetMappingIfNeeded() checks if any outputs of this step are still connected. If not, resetMapping() clears the step’s map-over and propagates the reset to connected output steps by rebuilding their input terminals’ map-over.

Constraint Propagation

When a step is mapped over and its outputs are connected to downstream steps, the map-over state constrains what can be connected to the original step’s other inputs. The _mappingConstraints() method collects these constraints from the current mapOver and from output steps’ mapOvers. The attachable() methods check new connections against these constraints.


The Terminal Factory

terminalFactory() is the single entry point for creating terminal instances. It uses type discriminators on TerminalSource to select the correct class:

Input side (has input_type field):
  valid === false       -> InvalidInputTerminal
  input_type "dataset"  -> InputTerminal
  input_type "dataset_collection" -> InputCollectionTerminal
  input_type "parameter" -> InputParameterTerminal

Output side (has name, no input_type):
  parameter === true    -> OutputParameterTerminal
  collection === true   -> OutputCollectionTerminal
  has extensions        -> OutputTerminal

Fallback:
  valid === false       -> InvalidOutputTerminal
  otherwise             -> throw Error

The factory also carries a conditional type TerminalOf<T> that maps source types to terminal class types at the TypeScript level, providing type-safe returns.


Integration with Vue Components

useTerminal Composable

client/src/components/Workflow/Editor/composables/useTerminal.ts

This is the reactivity bridge. It watches:

  • The step object (from stepStore)
  • The terminal source definition
  • The datatypes mapper

On any change, it rebuilds the terminal via terminalFactory() and calls getInvalidConnectedTerminals() to mark/clear invalid connections. Returns:

  • terminal: Ref<ReturnType<typeof terminalFactory>> — the current terminal instance
  • isMappedOver: ComputedRef<boolean> — whether the step has a collection map-over

NodeInput.vue

Uses useTerminal() to get an InputTerminals instance. Key behaviors:

  • Renders the input connector (drop target for drag-and-drop connections)
  • Computes canAccept against the currently dragged terminal (stateStore.draggingTerminal)
  • Shows accept/reject visual indicators and tooltip messages
  • On drop: deserializes drag data, creates an output terminal via terminalFactory, calls terminal.canAccept() then terminal.connect()
  • On remove button click: disconnects all connections via terminal.disconnect()
  • Reports position to stateStore.setInputTerminalPosition() for edge rendering

NodeOutput.vue

Uses useTerminal() to get an OutputTerminals instance. Key behaviors:

  • Renders the output connector (draggable source)
  • Wraps in DraggableWrapper for drag initiation
  • Emits onDragConnector with position and terminal reference during drag
  • Computes and displays output details (collection type description, mapped-over status)
  • Contains ConnectionMenu for keyboard-accessible connection picking
  • Reports position to stateStore.setOutputTerminalPosition() for edge rendering

ConnectionMenu.vue

Takes an OutputTerminals prop. Uses:

  • terminal.validInputTerminals() to list all compatible inputs in the workflow
  • terminal.getConnectedTerminals() to list currently connected inputs
  • terminalFactory() to rebuild input terminals for toggle operations
  • inputTerminal.connect()/disconnect() for connection management

WorkflowEdges.vue

Uses the OutputTerminals type for the draggingTerminal prop. Creates a temporary Connection object from the dragging terminal for rendering the in-progress edge.

WorkflowGraph.vue

Uses OutputTerminals type for tracking the currently dragged terminal across the graph.

Node.vue

Imports OutputTerminals type for event handling when a connector drag starts.


Store Dependencies

The stores parameter passed to every terminal is the return type of useWorkflowStores():

{
  connectionStore,  // Connection list, terminal indexes, invalid connection tracking
  stateStore,       // UI: positions, dragging state, scale
  stepStore,        // Step definitions, map-over state, step CRUD
  commentStore,     // Workflow comments (not used by terminals)
  toolbarStore,     // Toolbar state (not used by terminals)
  undoRedoStore,    // Undo/redo action stack
  searchStore,      // Search state (not used by terminals)
}

Terminals directly use:

  • connectionStore: addConnection, removeConnection, getConnectionsForTerminal, getConnectionsForStep, getOutputTerminalsForInputTerminal, markInvalidConnection, dropFromInvalidConnections, connections
  • stepStore: getStep, getStepExtraInputs, stepMapOver, stepInputMapOver, changeStepMapOver, changeStepInputMapOver, resetStepInputMapOver, steps
  • undoRedoStore: action() (for undo/redo wrapping of connect/disconnect)

Linting Integration

client/src/components/Workflow/Editor/modules/linting.ts

The linting module uses terminalFactory in getDisconnectedInputs() to:

  1. Build an input terminal for each step input
  2. Check if it’s non-optional and has no connections
  3. Report it as a lint warning

Data Flow Summary

Step Definition (from server/API)
  |
  v
workflowStepStore.steps[id].inputs/outputs   (TerminalSource data)
  |
  v
terminalFactory(stepId, source, datatypesMapper, stores)
  |
  v
Terminal instance (InputTerminal, OutputCollectionTerminal, etc.)
  |
  +-- reads: connectionStore (connections for this terminal)
  +-- reads: stepStore (mapOver state, step definitions)
  +-- writes: connectionStore (addConnection, removeConnection)
  +-- writes: stepStore (changeStepMapOver, changeStepInputMapOver)
  +-- writes: undoRedoStore (wraps connect/disconnect in undo actions)
  |
  v
useTerminal composable (Vue watcher rebuilds terminal on state change)
  |
  v
NodeInput.vue / NodeOutput.vue (render, drag/drop, canAccept display)
  |
  v
stateStore (terminal positions for SVG edge rendering)

Key Design Decisions

  1. Terminals are not reactive objects. They are rebuilt by useTerminal whenever tracked dependencies (step, terminalSource, datatypesMapper) change. This avoids complex reactive class hierarchies but means terminal instances can become stale.

  2. Undo/redo wrapping at the terminal level. connect() and disconnect() create undo/redo actions directly, so every connection change is undoable.

  3. Map-over state is stored in the step store, not on terminals. Terminals read and write stepMapOver and stepInputMapOver but don’t own the state. This allows the state to survive terminal reconstruction.

  4. The factory pattern centralizes construction. All terminal creation goes through terminalFactory, making it the single point to modify for adding new terminal types.

  5. EventEmitter inheritance is vestigial. The emit functionality is not actively used; the module communicates through store mutations instead.

  6. Invalid terminals are first-class. InvalidInputTerminal and InvalidOutputTerminal represent broken states gracefully rather than throwing errors, allowing the UI to display and recover from inconsistencies.


Collection Type Handling Summary

ScenarioExampleResult
Dataset output -> Dataset inputtabular -> txt (subtype)Direct connection
List output -> Dataset inputlist:tabular -> txtMap over list, run per element
List output -> List inputlist -> listDirect match, no map over
List:paired output -> Paired inputlist:paired -> pairedMap over list
List:paired output -> Dataset inputlist:paired -> txtMap over list:paired
List output -> Multiple data inputlist -> multipleConsumed as list (no map over)
List:list output -> Multiple data inputlist:list -> multipleMap over outer list
Paired_or_unpaired output -> Paired inputpaired_or_unpaired -> pairedREJECTED (use Split tool)
Paired_or_unpaired output -> Dataset inputpaired_or_unpaired -> txtMap over paired_or_unpaired
Any collection output -> Any collection inputany -> anyMatches any collection
Dataset output -> Collection inputdata -> collectionREJECTED
Parameter output -> Data inputinteger -> txtREJECTED

Test Coverage

client/src/components/Workflow/Editor/modules/terminals.test.ts (928 lines) covers:

  • terminalFactory: Correct class instantiation for all terminal source types
  • canAccept: ~42 scenarios including:
    • Simple data connections
    • Collection -> data mapping
    • Multi-data input list consumption
    • Map-over constraint propagation through output connections
    • Transitive map-over tracking
    • Parameter type matching/rejection
    • Optional -> required rejection
    • Collection type incompatibility
    • paired_or_unpaired special cases
    • Invalid connection detection and cleanup
    • Collection type source resolution (filter_failed pattern)
  • Input terminal state: Connection state, map-over inference, validation
  • producesAcceptableDatatype: Datatype hierarchy checks, unknown type handling

Incoming References (7)