mirror of
https://github.com/OPM/ResInsight.git
synced 2026-10-25 06:38:13 -05:00
Ports holding a ResInsight object (instance, case, view, well path, surface) show the project tree icon of that object in front of the type. The rips class is taken from the schema's Python bases, directly or via ObjectModel[Class].
896 lines
43 KiB
C++
896 lines
43 KiB
C++
/////////////////////////////////////////////////////////////////////////////////
|
|
//
|
|
// Copyright (C) 2026- Equinor ASA
|
|
//
|
|
// ResInsight is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY
|
|
// WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
|
// FITNESS FOR A PARTICULAR PURPOSE.
|
|
//
|
|
// See the GNU General Public License at <http://www.gnu.org/licenses/gpl.html>
|
|
// for more details.
|
|
//
|
|
/////////////////////////////////////////////////////////////////////////////////
|
|
|
|
#include "RimWorkflowDefinitionTools.h"
|
|
|
|
#include "RimWorkflowPortCompatibility.h"
|
|
#include "RimWorkflowSchemaTools.h"
|
|
#include "RimWorkflowTaskCatalog.h"
|
|
|
|
#include <QJsonArray>
|
|
#include <QRegularExpression>
|
|
#include <QSet>
|
|
|
|
namespace
|
|
{
|
|
const QString explicitConfigKey = "x_ri_explicit_config_fields";
|
|
|
|
QStringList stringList( const QJsonValue& value )
|
|
{
|
|
QStringList result;
|
|
for ( const QJsonValue& item : value.toArray() )
|
|
{
|
|
if ( item.isString() ) result.append( item.toString() );
|
|
}
|
|
return result;
|
|
}
|
|
|
|
QJsonValue nullIfEmpty( const QString& text )
|
|
{
|
|
return text.isEmpty() ? QJsonValue( QJsonValue::Null ) : QJsonValue( text );
|
|
}
|
|
|
|
RimWorkflowDefinitionNode nodeFromJson( const QJsonObject& json )
|
|
{
|
|
RimWorkflowDefinitionNode node;
|
|
node.name = json.value( "name" ).toString();
|
|
node.taskId = json.value( "task" ).toString();
|
|
node.configFields = stringList( json.value( "config_fields" ) );
|
|
node.explicitConfigFields = json.contains( explicitConfigKey ) ? stringList( json.value( explicitConfigKey ) ) : node.configFields;
|
|
for ( auto it = json.begin(); it != json.end(); ++it )
|
|
{
|
|
if ( it.key() != "name" && it.key() != "task" && it.key() != "config_fields" && it.key() != explicitConfigKey )
|
|
node.extra.insert( it.key(), it.value() );
|
|
}
|
|
return node;
|
|
}
|
|
|
|
QJsonObject nodeToJson( const RimWorkflowDefinitionNode& node )
|
|
{
|
|
QJsonObject json = node.extra;
|
|
json["name"] = node.name;
|
|
json["task"] = node.taskId;
|
|
json["config_fields"] = QJsonArray::fromStringList( node.configFields );
|
|
json[explicitConfigKey] = QJsonArray::fromStringList( node.explicitConfigFields );
|
|
return json;
|
|
}
|
|
|
|
QJsonObject edgeToJson( const RimWorkflowDefinitionEdge& edge )
|
|
{
|
|
return QJsonObject{ { "from", edge.from },
|
|
{ "output", nullIfEmpty( edge.output ) },
|
|
{ "to", edge.to },
|
|
{ "input", nullIfEmpty( edge.input ) } };
|
|
}
|
|
|
|
QJsonObject issueToJson( const RimWorkflowIssue& issue )
|
|
{
|
|
return QJsonObject{ { "task", issue.task },
|
|
{ "field", issue.field },
|
|
{ "severity", issue.severity == RimWorkflowIssue::Severity::Error ? "error" : "warning" },
|
|
{ "message", issue.message } };
|
|
}
|
|
|
|
void addIssue( std::vector<RimWorkflowIssue>& issues, const QString& task, const QString& field, const QString& message, bool isError = true )
|
|
{
|
|
RimWorkflowIssue issue{ .task = task,
|
|
.field = field,
|
|
.severity = isError ? RimWorkflowIssue::Severity::Error : RimWorkflowIssue::Severity::Warning,
|
|
.message = message };
|
|
if ( std::find( issues.begin(), issues.end(), issue ) == issues.end() ) issues.push_back( issue );
|
|
}
|
|
|
|
bool hasCycle( const RimWorkflowDefinition& definition )
|
|
{
|
|
QMap<QString, int> inDegree;
|
|
for ( const auto& node : definition.nodes )
|
|
inDegree[node.name] = 0;
|
|
for ( const auto& edge : definition.edges )
|
|
{
|
|
if ( inDegree.contains( edge.to ) && inDegree.contains( edge.from ) ) inDegree[edge.to]++;
|
|
}
|
|
|
|
QStringList ready;
|
|
for ( auto it = inDegree.begin(); it != inDegree.end(); ++it )
|
|
{
|
|
if ( it.value() == 0 ) ready.append( it.key() );
|
|
}
|
|
int visited = 0;
|
|
while ( !ready.isEmpty() )
|
|
{
|
|
const QString current = ready.takeLast();
|
|
++visited;
|
|
for ( const auto& edge : definition.edges )
|
|
{
|
|
if ( edge.from != current || !inDegree.contains( edge.to ) ) continue;
|
|
if ( --inDegree[edge.to] == 0 ) ready.append( edge.to );
|
|
}
|
|
}
|
|
return visited != inDegree.size();
|
|
}
|
|
|
|
void validateConnections( const RimWorkflowDefinition& definition, const RimWorkflowDefinitionNode& node, std::vector<RimWorkflowIssue>& issues )
|
|
{
|
|
const auto incoming = definition.incomingEdges( node.name );
|
|
int wholeCount = 0;
|
|
QSet<QString> fieldInputs;
|
|
for ( const auto& edge : incoming )
|
|
{
|
|
if ( edge.isWholeInput() )
|
|
{
|
|
++wholeCount;
|
|
continue;
|
|
}
|
|
if ( fieldInputs.contains( edge.input ) )
|
|
addIssue( issues, node.name, edge.input, QString( "Input '%1' has more than one connection" ).arg( edge.input ) );
|
|
fieldInputs.insert( edge.input );
|
|
}
|
|
if ( wholeCount > 1 ) addIssue( issues, node.name, {}, "The whole input has more than one connection" );
|
|
if ( wholeCount > 0 && !fieldInputs.isEmpty() )
|
|
addIssue( issues, node.name, {}, "The task takes both its whole input and individual input fields from other tasks" );
|
|
if ( wholeCount > 1 || ( wholeCount > 0 && !fieldInputs.isEmpty() ) ) return;
|
|
|
|
for ( const auto& edge : incoming )
|
|
{
|
|
if ( !definition.findNode( edge.from ) ) continue;
|
|
RimWorkflowDefinition others = definition;
|
|
std::erase( others.edges, edge );
|
|
auto check = RimWorkflowPortCompatibility::canConnect( others, edge.from, edge.output, edge.to, edge.input );
|
|
if ( !check ) addIssue( issues, node.name, edge.input, check.error() );
|
|
}
|
|
}
|
|
|
|
void validateInputs( const RimWorkflowDefinition& definition, const RimWorkflowDefinitionNode& node, std::vector<RimWorkflowIssue>& issues )
|
|
{
|
|
const QJsonObject taskType = definition.taskType( node.taskId );
|
|
const QStringList covered = RimWorkflowDefinitionTools::coveredInputFields( definition, node.name );
|
|
const auto incoming = definition.incomingEdges( node.name );
|
|
|
|
bool hasFieldIssue = false;
|
|
for ( const auto& port : RimWorkflowPortCompatibility::inputPorts( taskType ) )
|
|
{
|
|
if ( port.isWhole() || !port.required || covered.contains( port.name ) || node.configFields.contains( port.name ) ) continue;
|
|
const QString message = port.configurable ? QString( "Input '%1' is required; connect or configure it" ).arg( port.name )
|
|
: QString( "Input '%1' (%2) must be connected" ).arg( port.name, port.typeName );
|
|
addIssue( issues, node.name, port.name, message );
|
|
hasFieldIssue = true;
|
|
}
|
|
|
|
const QJsonObject inputSchema = taskType.value( "input_schema" ).toObject();
|
|
const bool hasFields = !inputSchema.value( "properties" ).toObject().isEmpty();
|
|
if ( incoming.empty() && hasFields && node.configFields.isEmpty() && !hasFieldIssue )
|
|
{
|
|
const QString message =
|
|
RimWorkflowPortCompatibility::inputPorts( taskType ).size() > 1
|
|
? QString( "No inputs are connected or configured; configure an optional input or connect the task" )
|
|
: QString( "The input (%1) must be connected" ).arg( RimWorkflowPortCompatibility::typeName( inputSchema, inputSchema ) );
|
|
addIssue( issues, node.name, {}, message );
|
|
}
|
|
|
|
const QJsonObject properties = inputSchema.value( "properties" ).toObject();
|
|
for ( const QString& field : node.configFields )
|
|
{
|
|
if ( !properties.contains( field ) )
|
|
addIssue( issues, node.name, field, QString( "Configured field '%1' is not an input of the task" ).arg( field ) );
|
|
}
|
|
}
|
|
|
|
RimWorkflowDefinition withoutInvalidReferences( RimWorkflowDefinition definition )
|
|
{
|
|
const QStringList names = definition.nodeNames();
|
|
std::erase_if( definition.edges,
|
|
[&names]( const RimWorkflowDefinitionEdge& edge ) { return !names.contains( edge.from ) || !names.contains( edge.to ); } );
|
|
if ( !definition.resultTask.isEmpty() && !names.contains( definition.resultTask ) ) definition.resultTask.clear();
|
|
|
|
QJsonObject inputs;
|
|
for ( const QString& name : names )
|
|
inputs.insert( name, definition.inputs.value( name ).toObject() );
|
|
definition.inputs = inputs;
|
|
return definition;
|
|
}
|
|
|
|
QJsonArray inputPortsJson( const RimWorkflowDefinition& definition,
|
|
const RimWorkflowDefinitionNode& node,
|
|
const std::vector<RimWorkflowPort>& ports,
|
|
const std::vector<RimWorkflowDefinitionEdge>& incoming )
|
|
{
|
|
const QStringList covered = RimWorkflowDefinitionTools::coveredInputFields( definition, node.name );
|
|
QJsonArray result;
|
|
for ( const auto& port : ports )
|
|
{
|
|
bool wired = false;
|
|
for ( const auto& edge : incoming )
|
|
wired = wired || edge.input == port.name;
|
|
result.append( QJsonObject{ { "name", port.name },
|
|
{ "type", port.typeName },
|
|
{ "icon", port.iconResource },
|
|
{ "description", port.description },
|
|
{ "required", port.required },
|
|
{ "configurable", port.configurable },
|
|
{ "configured", !port.isWhole() && node.configFields.contains( port.name ) },
|
|
{ "covered", !port.isWhole() && covered.contains( port.name ) },
|
|
{ "wired", wired } } );
|
|
}
|
|
return result;
|
|
}
|
|
|
|
QJsonArray outputPortsJson( const std::vector<RimWorkflowPort>& ports, const std::vector<RimWorkflowDefinitionEdge>& outgoing )
|
|
{
|
|
QJsonArray result;
|
|
for ( const auto& port : ports )
|
|
{
|
|
bool wired = false;
|
|
for ( const auto& edge : outgoing )
|
|
wired = wired || edge.output == port.name;
|
|
result.append( QJsonObject{ { "name", port.name },
|
|
{ "type", port.typeName },
|
|
{ "icon", port.iconResource },
|
|
{ "description", port.description },
|
|
{ "wired", wired } } );
|
|
}
|
|
return result;
|
|
}
|
|
|
|
QJsonObject graphTask( const RimWorkflowDefinition& definition,
|
|
const RimWorkflowDefinitionNode& node,
|
|
const QString& resultTask,
|
|
const std::vector<RimWorkflowIssue>& issues )
|
|
{
|
|
const QJsonObject taskType = definition.taskType( node.taskId );
|
|
const QJsonObject inputSchema = taskType.value( "input_schema" ).toObject();
|
|
const auto inputPorts = RimWorkflowPortCompatibility::inputPorts( taskType );
|
|
const auto outputPorts = RimWorkflowPortCompatibility::outputPorts( taskType );
|
|
const QStringList required = RimWorkflowSchemaTools::requiredFields( inputSchema );
|
|
const QJsonObject configValues = definition.inputs.value( node.name ).toObject();
|
|
|
|
QJsonArray inputs;
|
|
for ( const auto& port : inputPorts )
|
|
{
|
|
if ( !port.isWhole() ) inputs.append( port.name );
|
|
}
|
|
QJsonArray outputs;
|
|
for ( const auto& port : outputPorts )
|
|
{
|
|
if ( !port.isWhole() ) outputs.append( port.name );
|
|
}
|
|
QJsonArray configFields;
|
|
for ( const QString& field : node.configFields )
|
|
configFields.append( RimWorkflowSchemaTools::configFieldSchema( field, inputSchema, required, configValues ) );
|
|
|
|
QJsonArray taskIssues;
|
|
for ( const auto& issue : issues )
|
|
{
|
|
if ( issue.task == node.name ) taskIssues.append( issueToJson( issue ) );
|
|
}
|
|
|
|
return QJsonObject{ { "name", node.name },
|
|
{ "task_id", node.taskId },
|
|
{ "task_name", taskType.value( "name" ).toString() },
|
|
{ "description", taskType.value( "description" ).toString() },
|
|
{ "known_type", !taskType.isEmpty() },
|
|
{ "inputs", inputs },
|
|
{ "outputs", outputs },
|
|
{ "config_fields", configFields },
|
|
{ "accepts_input", !inputSchema.value( "properties" ).toObject().isEmpty() },
|
|
{ "whole_input", inputPorts.size() == 1 && !inputSchema.value( "properties" ).toObject().isEmpty() },
|
|
{ "input_ports", inputPortsJson( definition, node, inputPorts, definition.incomingEdges( node.name ) ) },
|
|
{ "output_ports", outputPortsJson( outputPorts, definition.outgoingEdges( node.name ) ) },
|
|
{ "input_types", RimWorkflowPortCompatibility::portTypes( inputPorts ) },
|
|
{ "output_types", RimWorkflowPortCompatibility::portTypes( outputPorts ) },
|
|
{ "input_icons", RimWorkflowPortCompatibility::portIcons( inputPorts ) },
|
|
{ "output_icons", RimWorkflowPortCompatibility::portIcons( outputPorts ) },
|
|
{ "is_result", node.name == resultTask },
|
|
{ "issues", taskIssues } };
|
|
}
|
|
} // namespace
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
std::expected<RimWorkflowDefinition, QString> RimWorkflowDefinitionTools::fromJson( const QJsonObject& json )
|
|
{
|
|
if ( json.value( "status" ).toString() == "invalid" )
|
|
{
|
|
const QString message = json.value( "error" ).toObject().value( "message" ).toString();
|
|
return std::unexpected( message.isEmpty() ? QString( "Invalid workflow definition" ) : message );
|
|
}
|
|
if ( json.contains( "format" ) && json.value( "format" ).toInt() != 1 )
|
|
return std::unexpected( QString( "Unsupported workflow definition format %1" ).arg( json.value( "format" ).toInt() ) );
|
|
if ( !json.value( "nodes" ).isArray() ) return std::unexpected( "The workflow definition has no task list" );
|
|
|
|
RimWorkflowDefinition definition;
|
|
definition.name = json.value( "name" ).toString();
|
|
definition.resultTask = json.value( "result_task" ).toString();
|
|
definition.headerComment = json.value( "header_comment" ).toString();
|
|
definition.passthrough = json.value( "passthrough" ).toObject();
|
|
definition.source = json.value( "source" ).toObject();
|
|
definition.inputs = json.value( "inputs" ).toObject();
|
|
definition.taskTypes = json.value( "task_types" ).toObject();
|
|
definition.editable = json.value( "editable" ).toBool( true );
|
|
definition.readOnlyReasons = stringList( json.value( "readonly_reasons" ) );
|
|
definition.warnings = stringList( json.value( "warnings" ) );
|
|
definition.describe = json.value( "describe" ).toObject();
|
|
definition.describeError = json.value( "describe_error" ).toObject();
|
|
|
|
for ( const QJsonValue& value : json.value( "nodes" ).toArray() )
|
|
{
|
|
auto node = nodeFromJson( value.toObject() );
|
|
if ( node.name.isEmpty() || node.taskId.isEmpty() )
|
|
return std::unexpected( "A task in the workflow definition has no name or type" );
|
|
definition.nodes.push_back( node );
|
|
}
|
|
|
|
for ( const QJsonValue& value : json.value( "edges" ).toArray() )
|
|
{
|
|
const QJsonObject edge = value.toObject();
|
|
definition.edges.push_back( { .from = edge.value( "from" ).toString(),
|
|
.output = edge.value( "output" ).toString(),
|
|
.to = edge.value( "to" ).toString(),
|
|
.input = edge.value( "input" ).toString() } );
|
|
}
|
|
return definition;
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
QJsonObject RimWorkflowDefinitionTools::toJson( const RimWorkflowDefinition& definition )
|
|
{
|
|
QJsonArray nodes;
|
|
for ( const auto& node : definition.nodes )
|
|
nodes.append( nodeToJson( node ) );
|
|
QJsonArray edges;
|
|
for ( const auto& edge : definition.edges )
|
|
edges.append( edgeToJson( edge ) );
|
|
|
|
return QJsonObject{ { "format", definition.format },
|
|
{ "name", definition.name },
|
|
{ "result_task", nullIfEmpty( definition.resultTask ) },
|
|
{ "editable", definition.editable },
|
|
{ "readonly_reasons", QJsonArray::fromStringList( definition.readOnlyReasons ) },
|
|
{ "warnings", QJsonArray::fromStringList( definition.warnings ) },
|
|
{ "source", definition.source },
|
|
{ "header_comment", definition.headerComment },
|
|
{ "passthrough", definition.passthrough },
|
|
{ "nodes", nodes },
|
|
{ "edges", edges },
|
|
{ "inputs", definition.inputs },
|
|
{ "task_types", definition.taskTypes },
|
|
{ "describe", definition.describe.isEmpty() ? QJsonValue( QJsonValue::Null ) : QJsonValue( definition.describe ) },
|
|
{ "describe_error",
|
|
definition.describeError.isEmpty() ? QJsonValue( QJsonValue::Null ) : QJsonValue( definition.describeError ) } };
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
RimWorkflowDefinition RimWorkflowDefinitionTools::createEmpty( const QString& name )
|
|
{
|
|
RimWorkflowDefinition definition;
|
|
definition.name = name;
|
|
definition.headerComment = "# Created in ResInsight";
|
|
return definition;
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
/// A copy that is saved as a new workflow. The source folder is kept for workflow-local modules.
|
|
//--------------------------------------------------------------------------------------------------
|
|
RimWorkflowDefinition RimWorkflowDefinitionTools::editableCopy( const RimWorkflowDefinition& definition, const QString& name )
|
|
{
|
|
RimWorkflowDefinition copy = definition;
|
|
copy.name = name;
|
|
copy.describe = {};
|
|
copy.describeError = {};
|
|
copy.warnings.clear();
|
|
if ( copy.headerComment.isEmpty() ) copy.headerComment = QString( "# Copy of %1, created in ResInsight" ).arg( definition.name );
|
|
return copy;
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
/// Parts of the workflow the editor does not model (nested workflows, `map:`). A copy of such a
|
|
/// workflow stays read-only.
|
|
//--------------------------------------------------------------------------------------------------
|
|
QStringList RimWorkflowDefinitionTools::structuralReadOnlyReasons( const RimWorkflowDefinition& definition )
|
|
{
|
|
QStringList reasons;
|
|
for ( const auto& node : definition.nodes )
|
|
{
|
|
if ( !node.extra.isEmpty() )
|
|
reasons << QString( "Task '%1' uses '%2', which the editor does not support" ).arg( node.name, node.extra.keys().join( "', '" ) );
|
|
}
|
|
return reasons;
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
bool RimWorkflowDefinitionTools::isIdentifier( const QString& name )
|
|
{
|
|
static const QRegularExpression pattern( "^[A-Za-z_][A-Za-z0-9_]*$" );
|
|
return pattern.match( name ).hasMatch();
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
/// Turn arbitrary text (a task id, a folder name) into a Python identifier
|
|
//--------------------------------------------------------------------------------------------------
|
|
QString RimWorkflowDefinitionTools::identifierFrom( const QString& text )
|
|
{
|
|
QString result;
|
|
for ( const QChar c : text )
|
|
{
|
|
const bool valid = ( c >= 'a' && c <= 'z' ) || ( c >= 'A' && c <= 'Z' ) || ( c >= '0' && c <= '9' ) || c == '_';
|
|
result += valid ? c : QChar( '_' );
|
|
}
|
|
if ( result.isEmpty() || result.front().isDigit() ) result.prepend( '_' );
|
|
return result;
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
QString RimWorkflowDefinitionTools::uniqueInstanceName( const RimWorkflowDefinition& definition, const QString& baseName )
|
|
{
|
|
const QString base = identifierFrom( baseName );
|
|
if ( !definition.findNode( base ) ) return base;
|
|
for ( int index = 2;; ++index )
|
|
{
|
|
const QString candidate = QString( "%1_%2" ).arg( base ).arg( index );
|
|
if ( !definition.findNode( candidate ) ) return candidate;
|
|
}
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
std::expected<void, QString>
|
|
RimWorkflowDefinitionTools::validateInstanceName( const RimWorkflowDefinition& definition, const QString& name, const QString& currentName )
|
|
{
|
|
if ( !isIdentifier( name ) )
|
|
return std::unexpected( QString( "'%1' is not a valid task name; use letters, digits and underscores" ).arg( name ) );
|
|
if ( name != currentName && definition.findNode( name ) )
|
|
return std::unexpected( QString( "A task named '%1' already exists" ).arg( name ) );
|
|
for ( const auto& node : definition.nodes )
|
|
{
|
|
if ( node.taskId == name ) return std::unexpected( QString( "'%1' is the id of a task type" ).arg( name ) );
|
|
}
|
|
return {};
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
/// Tasks without downstream tasks, in definition order
|
|
//--------------------------------------------------------------------------------------------------
|
|
QStringList RimWorkflowDefinitionTools::sinkTasks( const RimWorkflowDefinition& definition )
|
|
{
|
|
QStringList sinks;
|
|
for ( const auto& node : definition.nodes )
|
|
{
|
|
if ( definition.outgoingEdges( node.name ).empty() ) sinks.append( node.name );
|
|
}
|
|
return sinks;
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
/// The explicit result task, or the only sink. Empty when ambiguous.
|
|
//--------------------------------------------------------------------------------------------------
|
|
QString RimWorkflowDefinitionTools::effectiveResultTask( const RimWorkflowDefinition& definition )
|
|
{
|
|
if ( !definition.resultTask.isEmpty() ) return definition.resultTask;
|
|
const QStringList sinks = sinkTasks( definition );
|
|
return sinks.size() == 1 ? sinks.front() : QString();
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
/// Input fields that are supplied by connections
|
|
//--------------------------------------------------------------------------------------------------
|
|
QStringList RimWorkflowDefinitionTools::coveredInputFields( const RimWorkflowDefinition& definition, const QString& nodeName )
|
|
{
|
|
const QJsonObject taskType = definition.taskTypeForNode( nodeName );
|
|
QStringList covered;
|
|
for ( const auto& edge : definition.incomingEdges( nodeName ) )
|
|
{
|
|
if ( !edge.isWholeInput() )
|
|
{
|
|
covered.append( edge.input );
|
|
continue;
|
|
}
|
|
|
|
// A routed output field becomes the whole input value
|
|
if ( !edge.isWholeOutput() )
|
|
{
|
|
covered.append( RimWorkflowSchemaTools::propertyNames( taskType.value( "input_schema" ).toObject() ) );
|
|
continue;
|
|
}
|
|
covered.append( RimWorkflowPortCompatibility::coveredByWholeOutput( definition.taskTypeForNode( edge.from ), taskType ) );
|
|
}
|
|
covered.removeDuplicates();
|
|
return covered;
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
/// Config fields are the inputs that are not connected: required ones that can be entered in
|
|
/// ResInsight, and those the user explicitly configured. A routed output field allows no config.
|
|
//--------------------------------------------------------------------------------------------------
|
|
QStringList RimWorkflowDefinitionTools::deriveConfigFields( const RimWorkflowDefinition& definition, const QString& nodeName )
|
|
{
|
|
const auto* node = definition.findNode( nodeName );
|
|
if ( !node ) return {};
|
|
const QJsonObject taskType = definition.taskType( node->taskId );
|
|
if ( taskType.isEmpty() ) return node->configFields;
|
|
|
|
for ( const auto& edge : definition.incomingEdges( nodeName ) )
|
|
{
|
|
if ( edge.isWholeInput() && !edge.isWholeOutput() ) return {};
|
|
}
|
|
|
|
const QJsonObject inputSchema = taskType.value( "input_schema" ).toObject();
|
|
const QJsonObject properties = inputSchema.value( "properties" ).toObject();
|
|
const QStringList required = RimWorkflowSchemaTools::requiredFields( inputSchema );
|
|
const QStringList covered = coveredInputFields( definition, nodeName );
|
|
|
|
QStringList derived;
|
|
for ( const QString& field : RimWorkflowSchemaTools::propertyNames( inputSchema ) )
|
|
{
|
|
if ( covered.contains( field ) ) continue;
|
|
const bool configurable = RimWorkflowSchemaTools::isConfigurable( properties.value( field ).toObject(), inputSchema );
|
|
if ( node->explicitConfigFields.contains( field ) || ( required.contains( field ) && configurable ) ) derived.append( field );
|
|
}
|
|
|
|
// Keep the existing order and append new fields
|
|
QStringList result;
|
|
for ( const QString& field : node->configFields )
|
|
{
|
|
if ( derived.contains( field ) ) result.append( field );
|
|
}
|
|
for ( const QString& field : derived )
|
|
{
|
|
if ( !result.contains( field ) ) result.append( field );
|
|
}
|
|
return result;
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
/// Remove dangling references and derive the config fields of every task
|
|
//--------------------------------------------------------------------------------------------------
|
|
RimWorkflowDefinition RimWorkflowDefinitionTools::normalize( RimWorkflowDefinition definition )
|
|
{
|
|
if ( !definition.editable ) return definition;
|
|
|
|
definition = withoutInvalidReferences( definition );
|
|
|
|
std::vector<QStringList> configFields;
|
|
for ( const auto& node : definition.nodes )
|
|
configFields.push_back( deriveConfigFields( definition, node.name ) );
|
|
for ( size_t i = 0; i < definition.nodes.size(); ++i )
|
|
definition.nodes[i].configFields = configFields[i];
|
|
return definition;
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
RimWorkflowDefinitionTools::EditResult RimWorkflowDefinitionTools::addTask( const RimWorkflowDefinition& definition,
|
|
const RimWorkflowTaskCatalog& catalog,
|
|
const QString& taskId,
|
|
QString* addedName )
|
|
{
|
|
if ( !definition.editable ) return std::unexpected( "The workflow is read-only" );
|
|
|
|
QJsonObject descriptor = catalog.taskType( taskId );
|
|
if ( descriptor.isEmpty() ) descriptor = definition.taskType( taskId );
|
|
if ( descriptor.isEmpty() ) return std::unexpected( QString( "Unknown task type '%1'" ).arg( taskId ) );
|
|
|
|
QString baseName = descriptor.value( "name" ).toString();
|
|
if ( baseName.isEmpty() ) baseName = taskId.mid( taskId.lastIndexOf( '.' ) + 1 );
|
|
|
|
RimWorkflowDefinition result = definition;
|
|
const QString name = uniqueInstanceName( result, baseName );
|
|
result.nodes.push_back( { .name = name, .taskId = taskId } );
|
|
result.taskTypes.insert( taskId, descriptor );
|
|
result.inputs.insert( name, QJsonObject() );
|
|
result = autoWireResInsightInputs( result, catalog, name );
|
|
|
|
if ( addedName ) *addedName = name;
|
|
return normalize( result );
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
RimWorkflowDefinitionTools::EditResult RimWorkflowDefinitionTools::removeTask( const RimWorkflowDefinition& definition, const QString& nodeName )
|
|
{
|
|
if ( !definition.editable ) return std::unexpected( "The workflow is read-only" );
|
|
if ( !definition.findNode( nodeName ) ) return std::unexpected( QString( "Unknown task '%1'" ).arg( nodeName ) );
|
|
|
|
RimWorkflowDefinition result = definition;
|
|
std::erase_if( result.nodes, [&nodeName]( const RimWorkflowDefinitionNode& node ) { return node.name == nodeName; } );
|
|
return normalize( result );
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
/// Add a connection. An existing connection to the same input is replaced.
|
|
//--------------------------------------------------------------------------------------------------
|
|
RimWorkflowDefinitionTools::EditResult RimWorkflowDefinitionTools::connect( const RimWorkflowDefinition& definition,
|
|
const RimWorkflowDefinitionEdge& edge )
|
|
{
|
|
if ( !definition.editable ) return std::unexpected( "The workflow is read-only" );
|
|
|
|
auto check = RimWorkflowPortCompatibility::canConnect( definition, edge.from, edge.output, edge.to, edge.input );
|
|
if ( !check ) return std::unexpected( check.error() );
|
|
|
|
RimWorkflowDefinition result = definition;
|
|
std::erase_if( result.edges,
|
|
[&edge]( const RimWorkflowDefinitionEdge& existing ) { return existing.to == edge.to && existing.input == edge.input; } );
|
|
result.edges.push_back( edge );
|
|
return normalize( result );
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
RimWorkflowDefinitionTools::EditResult RimWorkflowDefinitionTools::disconnect( const RimWorkflowDefinition& definition,
|
|
const RimWorkflowDefinitionEdge& edge )
|
|
{
|
|
if ( !definition.editable ) return std::unexpected( "The workflow is read-only" );
|
|
|
|
RimWorkflowDefinition result = definition;
|
|
if ( std::erase( result.edges, edge ) == 0 ) return std::unexpected( "The connection does not exist" );
|
|
return normalize( result );
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
/// Rename a task instance, updating connections, the result task and the input values
|
|
//--------------------------------------------------------------------------------------------------
|
|
RimWorkflowDefinitionTools::EditResult
|
|
RimWorkflowDefinitionTools::renameTask( const RimWorkflowDefinition& definition, const QString& oldName, const QString& newName )
|
|
{
|
|
if ( !definition.editable ) return std::unexpected( "The workflow is read-only" );
|
|
if ( !definition.findNode( oldName ) ) return std::unexpected( QString( "Unknown task '%1'" ).arg( oldName ) );
|
|
if ( oldName == newName ) return definition;
|
|
if ( auto valid = validateInstanceName( definition, newName, oldName ); !valid ) return std::unexpected( valid.error() );
|
|
|
|
RimWorkflowDefinition result = definition;
|
|
result.findNode( oldName )->name = newName;
|
|
for ( auto& edge : result.edges )
|
|
{
|
|
if ( edge.from == oldName ) edge.from = newName;
|
|
if ( edge.to == oldName ) edge.to = newName;
|
|
}
|
|
if ( result.resultTask == oldName ) result.resultTask = newName;
|
|
|
|
const QJsonValue values = result.inputs.take( oldName );
|
|
result.inputs.insert( newName, values.isObject() ? values : QJsonObject() );
|
|
return normalize( result );
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
RimWorkflowDefinitionTools::EditResult RimWorkflowDefinitionTools::renameWorkflow( const RimWorkflowDefinition& definition,
|
|
const QString& newName )
|
|
{
|
|
if ( !definition.editable ) return std::unexpected( "The workflow is read-only" );
|
|
if ( !isIdentifier( newName ) )
|
|
return std::unexpected( QString( "'%1' is not a valid workflow name; use letters, digits and underscores" ).arg( newName ) );
|
|
|
|
RimWorkflowDefinition result = definition;
|
|
result.name = newName;
|
|
return result;
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
/// Set the explicit result task. An empty name clears it (the only sink is used).
|
|
//--------------------------------------------------------------------------------------------------
|
|
RimWorkflowDefinitionTools::EditResult RimWorkflowDefinitionTools::setResultTask( const RimWorkflowDefinition& definition,
|
|
const QString& nodeName )
|
|
{
|
|
if ( !definition.editable ) return std::unexpected( "The workflow is read-only" );
|
|
if ( !nodeName.isEmpty() && !definition.findNode( nodeName ) ) return std::unexpected( QString( "Unknown task '%1'" ).arg( nodeName ) );
|
|
|
|
RimWorkflowDefinition result = definition;
|
|
result.resultTask = nodeName;
|
|
return result;
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
/// Choose whether an optional input that is not connected is a config field
|
|
//--------------------------------------------------------------------------------------------------
|
|
RimWorkflowDefinitionTools::EditResult RimWorkflowDefinitionTools::setOptionalInputConfigured( const RimWorkflowDefinition& definition,
|
|
const QString& nodeName,
|
|
const QString& field,
|
|
bool configured )
|
|
{
|
|
if ( !definition.editable ) return std::unexpected( "The workflow is read-only" );
|
|
const auto* node = definition.findNode( nodeName );
|
|
if ( !node ) return std::unexpected( QString( "Unknown task '%1'" ).arg( nodeName ) );
|
|
|
|
const auto port = RimWorkflowPortCompatibility::inputPort( definition.taskType( node->taskId ), field );
|
|
if ( !port || port->isWhole() ) return std::unexpected( QString( "'%1' has no input '%2'" ).arg( nodeName, field ) );
|
|
if ( port->required ) return std::unexpected( QString( "Input '%1' is required and always configured unless connected" ).arg( field ) );
|
|
|
|
RimWorkflowDefinition result = definition;
|
|
QStringList& fields = result.findNode( nodeName )->explicitConfigFields;
|
|
fields.removeAll( field );
|
|
if ( configured ) fields.append( field );
|
|
return normalize( result );
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
/// Connect inputs that take a ResInsight connection to a `resinsight.connect` task, which is
|
|
/// created if the workflow has none
|
|
//--------------------------------------------------------------------------------------------------
|
|
RimWorkflowDefinition RimWorkflowDefinitionTools::autoWireResInsightInputs( const RimWorkflowDefinition& definition,
|
|
const RimWorkflowTaskCatalog& catalog,
|
|
const QString& nodeName )
|
|
{
|
|
const auto* node = definition.findNode( nodeName );
|
|
if ( !node || node->taskId == connectTaskId ) return definition;
|
|
|
|
QJsonObject connectType = catalog.taskType( connectTaskId );
|
|
if ( connectType.isEmpty() ) connectType = definition.taskType( connectTaskId );
|
|
if ( connectType.isEmpty() ) return definition;
|
|
|
|
const QJsonObject connectOutput = connectType.value( "output_schema" ).toObject();
|
|
if ( !connectOutput.contains( "x-ri-python-type" ) ) return definition;
|
|
const RimWorkflowPort produced{ .schema = connectOutput, .rootSchema = connectOutput };
|
|
|
|
const auto incoming = definition.incomingEdges( nodeName );
|
|
QStringList wiredInputs;
|
|
for ( const auto& edge : incoming )
|
|
wiredInputs.append( edge.input );
|
|
|
|
// Only inputs declared with the connection type; untyped (Any) inputs are left alone
|
|
auto takesConnection = [&produced]( const RimWorkflowPort& port )
|
|
{
|
|
const QJsonObject resolved = RimWorkflowSchemaTools::resolveReferences( port.schema, port.rootSchema );
|
|
return port.required && resolved.contains( "x-ri-python-type" ) && RimWorkflowPortCompatibility::isCompatible( produced, port );
|
|
};
|
|
|
|
QStringList inputsToWire;
|
|
const auto ports = RimWorkflowPortCompatibility::inputPorts( definition.taskType( node->taskId ) );
|
|
for ( const auto& port : ports )
|
|
{
|
|
if ( port.isWhole() || wiredInputs.contains( port.name ) || !takesConnection( port ) ) continue;
|
|
inputsToWire.append( port.name );
|
|
}
|
|
const bool wireWhole = inputsToWire.isEmpty() && incoming.empty() && ports.size() == 1 && takesConnection( ports.front() );
|
|
if ( inputsToWire.isEmpty() && !wireWhole ) return definition;
|
|
|
|
RimWorkflowDefinition result = definition;
|
|
QString connectName;
|
|
for ( const auto& candidate : result.nodes )
|
|
{
|
|
if ( candidate.taskId == connectTaskId )
|
|
{
|
|
connectName = candidate.name;
|
|
break;
|
|
}
|
|
}
|
|
if ( connectName.isEmpty() )
|
|
{
|
|
connectName = uniqueInstanceName( result, connectType.value( "name" ).toString( "connect_to_resinsight" ) );
|
|
result.nodes.insert( result.nodes.begin(), { .name = connectName, .taskId = connectTaskId } );
|
|
result.taskTypes.insert( connectTaskId, connectType );
|
|
result.inputs.insert( connectName, QJsonObject() );
|
|
}
|
|
|
|
if ( wireWhole ) result.edges.push_back( { .from = connectName, .to = nodeName } );
|
|
for ( const QString& input : inputsToWire )
|
|
result.edges.push_back( { .from = connectName, .to = nodeName, .input = input } );
|
|
return result;
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
/// Static checks that do not need Python. `taskmaestro workflow describe` has the final say.
|
|
//--------------------------------------------------------------------------------------------------
|
|
std::vector<RimWorkflowIssue> RimWorkflowDefinitionTools::validate( const RimWorkflowDefinition& definition )
|
|
{
|
|
std::vector<RimWorkflowIssue> issues;
|
|
if ( !isIdentifier( definition.name ) )
|
|
addIssue( issues, {}, {}, QString( "'%1' is not a valid workflow name" ).arg( definition.name ) );
|
|
if ( definition.nodes.empty() )
|
|
{
|
|
addIssue( issues, {}, {}, "The workflow has no tasks" );
|
|
return issues;
|
|
}
|
|
|
|
QSet<QString> names;
|
|
for ( const auto& node : definition.nodes )
|
|
{
|
|
if ( !isIdentifier( node.name ) ) addIssue( issues, node.name, {}, QString( "'%1' is not a valid task name" ).arg( node.name ) );
|
|
if ( names.contains( node.name ) ) addIssue( issues, node.name, {}, QString( "Duplicate task name '%1'" ).arg( node.name ) );
|
|
names.insert( node.name );
|
|
}
|
|
|
|
for ( const auto& edge : definition.edges )
|
|
{
|
|
if ( !names.contains( edge.from ) || !names.contains( edge.to ) )
|
|
addIssue( issues, edge.to, edge.input, QString( "Connection from unknown task '%1'" ).arg( edge.from ) );
|
|
}
|
|
if ( hasCycle( definition ) ) addIssue( issues, {}, {}, "The workflow contains a cycle" );
|
|
|
|
for ( const auto& node : definition.nodes )
|
|
{
|
|
if ( definition.taskType( node.taskId ).isEmpty() )
|
|
{
|
|
addIssue( issues, node.name, {}, QString( "Task type '%1' is not installed" ).arg( node.taskId ) );
|
|
continue;
|
|
}
|
|
validateConnections( definition, node, issues );
|
|
validateInputs( definition, node, issues );
|
|
}
|
|
|
|
if ( !definition.resultTask.isEmpty() && !names.contains( definition.resultTask ) )
|
|
{
|
|
addIssue( issues, {}, {}, QString( "The result task '%1' does not exist" ).arg( definition.resultTask ) );
|
|
}
|
|
else if ( definition.resultTask.isEmpty() )
|
|
{
|
|
const QStringList sinks = sinkTasks( definition );
|
|
if ( sinks.size() > 1 )
|
|
addIssue( issues,
|
|
{},
|
|
{},
|
|
QString( "The workflow has %1 end tasks (%2); set a result task" ).arg( sinks.size() ).arg( sinks.join( ", " ) ) );
|
|
}
|
|
return issues;
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
/// The graph shown by RiuWorkflowGraphView, in the shape produced by graphFromDescribe() plus the
|
|
/// ports, the result task and the issues of every task
|
|
//--------------------------------------------------------------------------------------------------
|
|
QJsonObject RimWorkflowDefinitionTools::graphFromDefinition( const RimWorkflowDefinition& definition,
|
|
bool editMode,
|
|
const std::vector<RimWorkflowIssue>& extraIssues )
|
|
{
|
|
std::vector<RimWorkflowIssue> issues = validate( definition );
|
|
for ( const auto& issue : extraIssues )
|
|
{
|
|
if ( std::find( issues.begin(), issues.end(), issue ) == issues.end() ) issues.push_back( issue );
|
|
}
|
|
|
|
const QString resultTask = effectiveResultTask( definition );
|
|
QJsonArray tasks;
|
|
for ( const auto& node : definition.nodes )
|
|
tasks.append( graphTask( definition, node, resultTask, issues ) );
|
|
|
|
QJsonArray edges;
|
|
for ( const auto& edge : definition.edges )
|
|
{
|
|
QJsonObject json{ { "from", edge.from }, { "to", edge.to } };
|
|
if ( !edge.input.isEmpty() ) json["input"] = edge.input;
|
|
if ( !edge.output.isEmpty() ) json["output"] = edge.output;
|
|
edges.append( json );
|
|
}
|
|
|
|
const QStringList nodeNames = definition.nodeNames();
|
|
QJsonArray workflowIssues;
|
|
for ( const auto& issue : issues )
|
|
{
|
|
if ( !nodeNames.contains( issue.task ) ) workflowIssues.append( issueToJson( issue ) );
|
|
}
|
|
|
|
return QJsonObject{ { "name", definition.name },
|
|
{ "description", definition.headerComment },
|
|
{ "editable", editMode && definition.editable },
|
|
{ "result_task", resultTask },
|
|
{ "tasks", tasks },
|
|
{ "edges", edges },
|
|
{ "issues", workflowIssues } };
|
|
}
|