Workflows: Add interactive editor for taskmaestro workflows

Build, copy and edit taskmaestro workflows in ResInsight and save them as
standard workflow.yaml + input.yaml.

- Python helper: catalog, load, save and export-registered subcommands,
  and run --registered for installed workflows
- C++ definition model with pure edit operations, port type checks,
  config field derivation, static validation and undo/redo
- Installed folder listing registered workflows (read-only, runnable,
  can be duplicated)
- Graph editor dock with task palette, drag-to-connect, context menus,
  automatic layout and debounced live validation
- New/Duplicate/Save/Save As/Rescan commands; unsaved edits run from a
  temporary folder and prompt before being discarded
- Update the design document and the user README
This commit is contained in:
Kristian Bendiksen committed 2026-10-23 20:23:54 +02:00
1 parent 1342e0987a
commit e6dc62a160
120 files changed
+12637 -678

No files matched your search

@@ -1,7 +1,15 @@
set(SOURCE_GROUP_SOURCE_FILES
${CMAKE_CURRENT_LIST_DIR}/RimWorkflow.cpp
${CMAKE_CURRENT_LIST_DIR}/RimWorkflowCollection.cpp
${CMAKE_CURRENT_LIST_DIR}/RimWorkflowValidationTools.cpp
${CMAKE_CURRENT_LIST_DIR}/RimWorkflowInstalledCollection.cpp
${CMAKE_CURRENT_LIST_DIR}/RimWorkflowDescribeTools.cpp
${CMAKE_CURRENT_LIST_DIR}/RimWorkflowSchemaTools.cpp
${CMAKE_CURRENT_LIST_DIR}/RimWorkflowDefinition.cpp
${CMAKE_CURRENT_LIST_DIR}/RimWorkflowDefinitionTools.cpp
${CMAKE_CURRENT_LIST_DIR}/RimWorkflowPortCompatibility.cpp
${CMAKE_CURRENT_LIST_DIR}/RimWorkflowHelperProcess.cpp
${CMAKE_CURRENT_LIST_DIR}/RimWorkflowTaskCatalog.cpp
${CMAKE_CURRENT_LIST_DIR}/RimWorkflowJob.cpp
${CMAKE_CURRENT_LIST_DIR}/RimWorkflowTaskInput.cpp
${CMAKE_CURRENT_LIST_DIR}/RimWorkflowFieldBinding.cpp
@@ -16,6 +24,7 @@ set(SOURCE_GROUP_SOURCE_FILES
${CMAKE_CURRENT_LIST_DIR}/RimWorkflowDateBinding.cpp
${CMAKE_CURRENT_LIST_DIR}/RimWorkflowFilePathBinding.cpp
${CMAKE_CURRENT_LIST_DIR}/RimWorkflowVec3Binding.cpp
${CMAKE_CURRENT_LIST_DIR}/RimWorkflowMappingBinding.cpp
)
list(APPEND CODE_SOURCE_FILES ${SOURCE_GROUP_SOURCE_FILES})
@@ -15,30 +15,37 @@
// for more details.
//
/////////////////////////////////////////////////////////////////////////////////
#include "RimWorkflow.h"
#include "RimWorkflowDefinitionTools.h"
#include "RimWorkflowDescribeTools.h"
#include "RimWorkflowHelperProcess.h"
#include "RimWorkflowJob.h"
#include "RimWorkflowTaskInput.h"
#include "RimWorkflowValidationTools.h"
#include "RiaLogging.h"
#include "RiaPreferences.h"
#include "RiaQStringFormatter.h"
#include "RiuMainWindow.h"
#include "cafCmdFeatureMenuBuilder.h"
#include "cafPdmUiOrdering.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QProcess>
#include <QProcessEnvironment>
#include <QStandardPaths>
#include <QTemporaryDir>
CAF_PDM_SOURCE_INIT( RimWorkflow, "Workflow" );
namespace
{
constexpr size_t maxUndoSteps = 100;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
@@ -55,12 +62,26 @@ RimWorkflow::RimWorkflow()
CAF_PDM_InitFieldNoDefault( &m_workflowDirectory, "WorkflowDirectory", "Directory" );
m_workflowDirectory.uiCapability()->setUiReadOnly( true );
CAF_PDM_InitFieldNoDefault( &m_registeredId, "RegisteredId", "Registered Id" );
m_registeredId.uiCapability()->setUiReadOnly( true );
CAF_PDM_InitField( &m_editable, "Editable", false, "Editable" );
m_editable.uiCapability()->setUiReadOnly( true );
CAF_PDM_InitFieldNoDefault( &m_readOnlyReason, "ReadOnlyReason", "Read-only Because" );
m_readOnlyReason.uiCapability()->setUiReadOnly( true );
CAF_PDM_InitFieldNoDefault( &m_loadError, "LoadError", "Load Error" );
m_loadError.uiCapability()->setUiReadOnly( true );
CAF_PDM_InitFieldNoDefault( &m_jobs, "Jobs", "" );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
RimWorkflow::~RimWorkflow() = default;
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
@@ -75,9 +96,9 @@ QString RimWorkflow::name() const
void RimWorkflow::setWorkflowDirectory( const QString& directory )
{
m_workflowDirectory = directory;
QString name = QFileInfo( QDir( directory ).absolutePath() ).fileName();
m_name = name;
setUiName( name );
m_sourceDirectory = directory;
m_name = QFileInfo( QDir( directory ).absolutePath() ).fileName();
updateUiName();
}
//--------------------------------------------------------------------------------------------------
@@ -111,9 +132,9 @@ std::vector<RimWorkflowJob*> RimWorkflow::jobs() const
{
std::vector<RimWorkflowJob*> result;
result.reserve( m_jobs.size() );
for ( RimWorkflowJob* j : m_jobs.childrenByType() )
for ( RimWorkflowJob* job : m_jobs.childrenByType() )
{
if ( j ) result.push_back( j );
if ( job ) result.push_back( job );
}
return result;
}
@@ -123,7 +144,19 @@ std::vector<RimWorkflowJob*> RimWorkflow::jobs() const
//--------------------------------------------------------------------------------------------------
void RimWorkflow::addJob( RimWorkflowJob* job )
{
if ( job ) m_jobs.push_back( job );
if ( !job ) return;
m_jobs.push_back( job );
job->syncTaskInputs( m_graph.value( "tasks" ).toArray() );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RimWorkflow::syncJobs( const QMap<QString, QString>& renames )
{
const QJsonArray tasks = m_graph.value( "tasks" ).toArray();
for ( RimWorkflowJob* job : jobs() )
job->syncTaskInputs( tasks, renames );
}
//--------------------------------------------------------------------------------------------------
@@ -132,115 +165,671 @@ void RimWorkflow::addJob( RimWorkflowJob* job )
void RimWorkflow::appendMenuItems( caf::CmdFeatureMenuBuilder& menuBuilder ) const
{
menuBuilder << "RicNewWorkflowJobFeature";
menuBuilder << "Separator";
menuBuilder << "RicSaveWorkflowFeature";
menuBuilder << "RicSaveWorkflowAsFeature";
menuBuilder << "RicDuplicateWorkflowAsEditableFeature";
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RimWorkflow::defineUiOrdering( QString uiConfigName, caf::PdmUiOrdering& uiOrdering )
{
m_name.uiCapability()->setUiReadOnly( !isEditable() || isLocked() );
uiOrdering.add( &m_name );
uiOrdering.add( &m_description );
if ( m_source == Source::Registered )
uiOrdering.add( &m_registeredId );
else if ( !workflowDirectory().isEmpty() )
uiOrdering.add( &m_workflowDirectory );
uiOrdering.add( &m_editable );
if ( !m_readOnlyReason().isEmpty() ) uiOrdering.add( &m_readOnlyReason );
if ( !m_loadError().isEmpty() ) uiOrdering.add( &m_loadError );
uiOrdering.skipRemainingFields( true );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RimWorkflow::fieldChangedByUi( const caf::PdmFieldHandle* changedField, const QVariant& oldValue, const QVariant& newValue )
{
if ( changedField != &m_name ) return;
auto result = applyEdit( RimWorkflowDefinitionTools::renameWorkflow( m_definition, m_name() ) );
if ( !result )
{
RiaLogging::warning( result.error().toStdString() );
m_name = m_definition.name;
updateUiName();
}
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
bool RimWorkflow::setLoadError( const QString& message, QString* errorMessage )
{
m_loadError = message;
RiaLogging::warning( QString( "Workflow '%1': %2" ).arg( m_name(), message ).toStdString() );
if ( errorMessage ) *errorMessage = message;
return false;
}
//--------------------------------------------------------------------------------------------------
/// Load a YAML folder through `taskmaestro_helper load --describe`
//--------------------------------------------------------------------------------------------------
bool RimWorkflow::loadFromDirectory( QString* errorMessage )
{
m_jobs.deleteChildren();
m_graph = {};
m_loadError = "";
const QString dir = workflowDirectory();
if ( dir.isEmpty() ) return false;
const QString directory = workflowDirectory();
if ( directory.isEmpty() ) return false;
if ( !QDir( directory ).exists( "workflow.yaml" ) ) return setLoadError( "Missing workflow.yaml", errorMessage );
QString python = RimWorkflow::findPythonExecutable();
if ( python.isEmpty() )
auto output = RimWorkflowHelperProcess::runSync( { "load", QDir( directory ).absolutePath(), "--describe" } );
if ( !output ) return setLoadError( output.error(), errorMessage );
auto definition = RimWorkflowDefinitionTools::fromJson( output.value() );
if ( !definition ) return setLoadError( definition.error(), errorMessage );
m_registeredId = "";
setDefinition( definition.value(), Source::YamlDirectory );
recordModificationTime();
const auto validation =
RimWorkflowValidationTools::issuesFromDescribe( m_definition.describe, m_definition.describeError, m_definition.nodeNames() );
setValidationResult( m_revision, validation.issues, validation.describeSucceeded );
if ( !validation.describeSucceeded )
{
m_loadError = "No usable Python interpreter found";
RiaLogging::warning( QString( "Workflow '%1': %2" ).arg( dir, m_loadError() ).toStdString() );
if ( errorMessage ) *errorMessage = m_loadError;
return false;
m_loadError = RimWorkflowHelperProcess::errorMessage( m_definition.describeError );
RiaLogging::warning( QString( "Workflow '%1' is not valid: %2" ).arg( m_name(), m_loadError() ).toStdString() );
}
const QDir workflowDir( dir );
if ( !workflowDir.exists( "workflow.yaml" ) )
RiaLogging::info(
QString( "Loaded workflow '%1' (%2 tasks) from %3" ).arg( m_name() ).arg( m_definition.nodes.size() ).arg( directory ).toStdString() );
return true;
}
//--------------------------------------------------------------------------------------------------
/// A workflow registered by an installed package, as listed by `taskmaestro_helper catalog`.
/// Registered workflows are read-only; Duplicate as Editable makes an editable copy.
//--------------------------------------------------------------------------------------------------
bool RimWorkflow::loadFromRegistered( const QJsonObject& catalogEntry, QString* errorMessage )
{
m_graph = {};
m_loadError = "";
m_registeredId = catalogEntry.value( "id" ).toString();
m_name = catalogEntry.value( "name" ).toString( m_registeredId() );
m_sourceDirectory.clear();
RimWorkflowDefinition definition;
if ( catalogEntry.value( "definition" ).isObject() )
{
m_loadError = "Missing workflow.yaml";
RiaLogging::warning( std::format( "Workflow '{}': {}", dir, m_loadError() ) );
if ( errorMessage ) *errorMessage = m_loadError;
return false;
auto parsed = RimWorkflowDefinitionTools::fromJson( catalogEntry.value( "definition" ).toObject() );
if ( !parsed ) return setLoadError( parsed.error(), errorMessage );
definition = parsed.value();
}
else
{
definition.name = m_name();
definition.editable = false;
}
QStringList args{ "-m", "taskmaestro", "workflow", "describe", workflowDir.absoluteFilePath( "workflow.yaml" ), "--json" };
if ( workflowDir.exists( "input.yaml" ) ) args << "--input" << workflowDir.absoluteFilePath( "input.yaml" );
QProcess proc;
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
proc.setProcessEnvironment( env );
proc.start( python, args );
if ( !proc.waitForStarted( 10000 ) )
// `definition.editable` tells whether an editable copy is possible; the installed workflow
// itself is always read-only (see isEditable())
for ( const QJsonValue& reason : catalogEntry.value( "readonly_reasons" ).toArray() )
{
m_loadError = QString( "Could not launch '%1'" ).arg( python );
RiaLogging::warning( QString( "Workflow '%1': %2" ).arg( dir, m_loadError() ).toStdString() );
if ( errorMessage ) *errorMessage = m_loadError;
return false;
}
if ( !proc.waitForFinished( 30000 ) )
{
proc.kill();
m_loadError = "taskmaestro workflow describe timed out";
RiaLogging::warning( QString( "Workflow '%1': %2" ).arg( dir, m_loadError() ).toStdString() );
if ( errorMessage ) *errorMessage = m_loadError;
return false;
if ( !definition.readOnlyReasons.contains( reason.toString() ) ) definition.readOnlyReasons.append( reason.toString() );
}
if ( !definition.readOnlyReasons.isEmpty() ) definition.editable = false;
if ( definition.editable ) definition.readOnlyReasons.append( "Installed workflows are read-only; duplicate to edit" );
if ( definition.describe.isEmpty() ) definition.describe = catalogEntry.value( "describe" ).toObject();
const QByteArray stdoutBytes = proc.readAllStandardOutput();
QJsonParseError parseErr{};
const QJsonDocument doc = QJsonDocument::fromJson( stdoutBytes, &parseErr );
setDefinition( definition, Source::Registered );
if ( m_graph.value( "tasks" ).toArray().isEmpty() ) return setLoadError( "The workflow could not be described", errorMessage );
return true;
}
if ( proc.exitStatus() != QProcess::NormalExit || proc.exitCode() != 0 )
{
// In JSON mode, configuration errors are reported on stdout; other errors on stderr
m_loadError = RimWorkflowDescribeTools::errorFromDescribe( doc.object() );
if ( m_loadError().isEmpty() ) m_loadError = QString::fromUtf8( proc.readAllStandardError() ).trimmed();
if ( m_loadError().isEmpty() ) m_loadError = "taskmaestro workflow describe failed";
RiaLogging::warning( std::format( "Workflow '{}' describe failed: {}", dir, m_loadError() ) );
if ( errorMessage ) *errorMessage = m_loadError;
return false;
}
//--------------------------------------------------------------------------------------------------
/// A new workflow, or an editable copy. `sourceDirectory` holds Python modules used by the tasks,
/// and `targetDirectory` is the folder the first Save writes to.
//--------------------------------------------------------------------------------------------------
void RimWorkflow::setUnsavedDefinition( const RimWorkflowDefinition& definition, const QString& sourceDirectory, const QString& targetDirectory )
{
m_workflowDirectory = targetDirectory;
m_registeredId = "";
m_loadError = "";
m_sourceDirectory = sourceDirectory;
if ( parseErr.error != QJsonParseError::NoError || !doc.isObject() )
{
m_loadError = QString( "Invalid JSON from taskmaestro workflow describe: %1" ).arg( parseErr.errorString() );
if ( errorMessage ) *errorMessage = m_loadError;
return false;
}
RimWorkflowDefinition editable = definition;
if ( definition.editable ) editable.readOnlyReasons.clear();
editable.readOnlyReasons.append( RimWorkflowDefinitionTools::structuralReadOnlyReasons( definition ) );
editable.readOnlyReasons.removeDuplicates();
editable.editable = editable.readOnlyReasons.isEmpty();
setDefinition( editable, Source::Unsaved );
}
if ( !doc.object().value( "tasks" ).isArray() )
{
m_loadError = "Invalid workflow graph schema";
if ( errorMessage ) *errorMessage = m_loadError;
return false;
}
const QJsonObject root = RimWorkflowDescribeTools::graphFromDescribe( doc.object() );
m_graph = root;
if ( root.contains( "name" ) ) m_name = root.value( "name" ).toString();
if ( root.contains( "description" ) ) m_description = root.value( "description" ).toString();
setUiName( m_name() );
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RimWorkflow::setDefinition( const RimWorkflowDefinition& definition, Source source )
{
m_source = source;
m_definition = definition;
m_savedDefinition = definition;
m_undoStack.clear();
m_redoStack.clear();
m_validationIssues.clear();
m_validationRevision = -1;
++m_revision;
std::vector<RimWorkflowTaskInput*> taskInputs;
for ( const QJsonValue& tv : root.value( "tasks" ).toArray() )
if ( !definition.name.isEmpty() ) m_name = definition.name;
m_description = definition.headerComment;
m_editable = isEditable();
m_readOnlyReason = definition.readOnlyReasons.join( "\n" );
rebuildGraph();
ensureDefaultJob();
syncJobs();
updateUiName();
notifyDefinitionChanged();
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
RimWorkflow::Source RimWorkflow::source() const
{
return m_source;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QString RimWorkflow::registeredId() const
{
return m_registeredId();
}
//--------------------------------------------------------------------------------------------------
/// The folder with the Python modules of the workflow (the YAML folder or the folder it was copied from)
//--------------------------------------------------------------------------------------------------
QString RimWorkflow::sourceDirectory() const
{
return m_sourceDirectory;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
const RimWorkflowDefinition& RimWorkflow::definition() const
{
return m_definition;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
bool RimWorkflow::isEditable() const
{
return m_definition.editable && m_source != Source::Registered;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QString RimWorkflow::readOnlyReason() const
{
return m_readOnlyReason();
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
bool RimWorkflow::isDirty() const
{
return m_source == Source::Unsaved || m_definition != m_savedDefinition;
}
//--------------------------------------------------------------------------------------------------
/// The workflow cannot be edited while one of its jobs is running
//--------------------------------------------------------------------------------------------------
bool RimWorkflow::isLocked() const
{
for ( RimWorkflowJob* job : jobs() )
{
const QJsonObject taskObj = tv.toObject();
if ( taskObj.value( "config_fields" ).toArray().isEmpty() ) continue;
auto* input = new RimWorkflowTaskInput;
input->setTaskName( taskObj.value( "name" ).toString() );
input->buildFromSchema( taskObj.value( "config_fields" ).toArray() );
taskInputs.push_back( input );
if ( job->isRunning() ) return true;
}
return false;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
int RimWorkflow::revision() const
{
return m_revision;
}
//--------------------------------------------------------------------------------------------------
/// Apply the result of an edit operation from RimWorkflowDefinitionTools. `renames` maps old task
/// names to new names, so that input values follow renamed tasks.
//--------------------------------------------------------------------------------------------------
std::expected<void, QString> RimWorkflow::applyEdit( const std::expected<RimWorkflowDefinition, QString>& result,
const QMap<QString, QString>& renames )
{
if ( !result ) return std::unexpected( result.error() );
if ( !isEditable() )
return std::unexpected( m_readOnlyReason().isEmpty() ? QString( "The workflow is read-only" ) : m_readOnlyReason() );
if ( isLocked() ) return std::unexpected( "The workflow cannot be edited while a job is running" );
if ( result.value() == m_definition ) return {};
m_undoStack.push_back( { m_definition, renames } );
if ( m_undoStack.size() > maxUndoSteps ) m_undoStack.erase( m_undoStack.begin() );
m_redoStack.clear();
m_definition = result.value();
definitionChanged( renames );
return {};
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
bool RimWorkflow::canUndo() const
{
return isEditable() && !isLocked() && !m_undoStack.empty();
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
bool RimWorkflow::canRedo() const
{
return isEditable() && !isLocked() && !m_redoStack.empty();
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QMap<QString, QString> RimWorkflow::undoRenames() const
{
return m_undoStack.empty() ? QMap<QString, QString>() : invertedRenames( m_undoStack.back().renames );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QMap<QString, QString> RimWorkflow::redoRenames() const
{
return m_redoStack.empty() ? QMap<QString, QString>() : m_redoStack.back().renames;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RimWorkflow::undo()
{
if ( !canUndo() ) return;
UndoEntry entry = m_undoStack.back();
m_undoStack.pop_back();
m_redoStack.push_back( { m_definition, entry.renames } );
m_definition = entry.definition;
definitionChanged( invertedRenames( entry.renames ) );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RimWorkflow::redo()
{
if ( !canRedo() ) return;
UndoEntry entry = m_redoStack.back();
m_redoStack.pop_back();
m_undoStack.push_back( { m_definition, entry.renames } );
m_definition = entry.definition;
definitionChanged( entry.renames );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RimWorkflow::definitionChanged( const QMap<QString, QString>& renames )
{
++m_revision;
m_name = m_definition.name;
m_description = m_definition.headerComment;
// Keep the issues that have not been checked again until the next validation
rebuildGraph();
syncJobs( renames );
updateUiName();
notifyDefinitionChanged();
}
//--------------------------------------------------------------------------------------------------
/// Let the workflow editor show the new state
//--------------------------------------------------------------------------------------------------
void RimWorkflow::notifyDefinitionChanged()
{
if ( auto* window = RiuMainWindow::instance() ) window->workflowDefinitionChanged( this );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QMap<QString, QString> RimWorkflow::invertedRenames( const QMap<QString, QString>& renames )
{
QMap<QString, QString> inverted;
for ( auto it = renames.begin(); it != renames.end(); ++it )
inverted.insert( it.value(), it.key() );
return inverted;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RimWorkflow::rebuildGraph()
{
if ( isEditable() )
{
std::vector<RimWorkflowIssue> describeIssues;
if ( isValidationCurrent() ) describeIssues = m_validationIssues;
m_graph = RimWorkflowDefinitionTools::graphFromDefinition( m_definition, true, describeIssues );
}
else if ( !m_definition.describe.isEmpty() )
{
m_graph = RimWorkflowDescribeTools::graphFromDescribe( m_definition.describe );
m_graph["editable"] = false;
}
else
{
m_graph = RimWorkflowDefinitionTools::graphFromDefinition( m_definition, false );
}
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RimWorkflow::updateUiName()
{
setUiName( isDirty() && isEditable() ? m_name() + " *" : m_name() );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RimWorkflow::ensureDefaultJob()
{
if ( !m_jobs.empty() ) return;
auto* job = new RimWorkflowJob;
job->setJobName( "Default" );
job->setTaskInputs( taskInputs );
m_jobs.push_back( job );
}
RiaLogging::info(
QString( "Loaded workflow '%1' (%2 tasks with config) from %3" ).arg( m_name() ).arg( taskInputs.size() ).arg( dir ).toStdString() );
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RimWorkflow::setValidationResult( int revision, const std::vector<RimWorkflowIssue>& issues, bool describeSucceeded )
{
if ( revision != m_revision ) return;
return true;
m_validationIssues = issues;
m_validationRevision = revision;
m_describeSucceeded = describeSucceeded;
rebuildGraph();
notifyDefinitionChanged();
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
std::vector<RimWorkflowIssue> RimWorkflow::validationIssues() const
{
return m_validationIssues;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
bool RimWorkflow::isValidationCurrent() const
{
return m_validationRevision == m_revision;
}
//--------------------------------------------------------------------------------------------------
/// True when the current revision has errors, from the static checks or from the last describe
//--------------------------------------------------------------------------------------------------
bool RimWorkflow::lastValidationFailed() const
{
if ( isValidationCurrent() && !m_describeSucceeded ) return true;
if ( !isEditable() ) return false;
for ( const auto& issue : RimWorkflowDefinitionTools::validate( m_definition ) )
{
if ( issue.severity == RimWorkflowIssue::Severity::Error ) return true;
}
return false;
}
//--------------------------------------------------------------------------------------------------
/// input.yaml content written on save: the literal values of the first job, falling back to the
/// values loaded from the file for tasks that have no inputs in the job
//--------------------------------------------------------------------------------------------------
QJsonObject RimWorkflow::inputValuesForSave() const
{
QJsonObject inputs = m_definition.inputs;
const auto allJobs = jobs();
if ( allJobs.empty() ) return inputs;
const QJsonObject literal = allJobs.front()->literalInputValues();
for ( const auto& node : m_definition.nodes )
{
if ( literal.contains( node.name ) ) inputs[node.name] = literal.value( node.name );
}
return inputs;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
std::expected<QJsonObject, QString> RimWorkflow::saveToDirectory( const QString& directory, bool backup )
{
QStringList args{ "save", "--out-dir", directory, "--describe" };
if ( backup ) args << "--backup";
auto output =
RimWorkflowHelperProcess::runSync( args, definitionJsonForSave(), RimWorkflowHelperProcess::defaultTimeoutMs, m_sourceDirectory );
if ( !output ) return output;
m_definition.inputs = inputValuesForSave();
m_definition.describe = output->value( "describe" ).toObject();
m_definition.describeError = output->value( "describe_error" ).toObject();
m_definition.source = QJsonObject{ { "workflow_yaml", output->value( "workflow_yaml" ) },
{ "input_yaml", output->value( "input_yaml" ) },
{ "registered_id", QJsonValue::Null } };
m_savedDefinition = m_definition;
m_source = Source::YamlDirectory;
m_loadedModificationTime = QFileInfo( QDir( directory ).absoluteFilePath( "workflow.yaml" ) ).lastModified();
const auto validation = RimWorkflowValidationTools::issuesFromHelperResult( output.value(), m_definition.nodeNames() );
setValidationResult( m_revision, validation.issues, validation.describeSucceeded );
updateUiName();
return output;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RimWorkflow::recordModificationTime()
{
m_loadedModificationTime = QFileInfo( QDir( workflowDirectory() ).absoluteFilePath( "workflow.yaml" ) ).lastModified();
}
//--------------------------------------------------------------------------------------------------
/// Reasons to think twice before overwriting the workflow folder: workflow.yaml was changed by
/// someone else since it was loaded, or the folder is a link into a git checkout
//--------------------------------------------------------------------------------------------------
QStringList RimWorkflow::saveInPlaceWarnings() const
{
QStringList warnings;
if ( m_source != Source::YamlDirectory || workflowDirectory().isEmpty() ) return warnings;
const QFileInfo yamlFile( QDir( workflowDirectory() ).absoluteFilePath( "workflow.yaml" ) );
if ( m_loadedModificationTime.isValid() && yamlFile.exists() && yamlFile.lastModified() != m_loadedModificationTime )
{
warnings << "workflow.yaml has been changed on disk since it was loaded. Saving overwrites those changes.";
}
const QFileInfo directoryInfo( workflowDirectory() );
if ( directoryInfo.isSymLink() || yamlFile.isSymLink() )
{
QDir dir( yamlFile.canonicalPath() );
do
{
if ( dir.exists( ".git" ) )
{
warnings
<< QString( "The workflow folder links into the git checkout '%1'. Saving changes files there." ).arg( dir.absolutePath() );
break;
}
} while ( dir.cdUp() );
}
return warnings;
}
//--------------------------------------------------------------------------------------------------
/// Write workflow.yaml and input.yaml to the workflow folder. A backup is made the first time.
//--------------------------------------------------------------------------------------------------
std::expected<void, QString> RimWorkflow::save()
{
if ( !isEditable() ) return std::unexpected( "The workflow is read-only" );
if ( workflowDirectory().isEmpty() ) return std::unexpected( "The workflow has no folder; use Save As" );
if ( m_source == Source::Unsaved ) return saveAs( workflowDirectory() );
auto output = saveToDirectory( workflowDirectory(), true );
if ( !output ) return std::unexpected( output.error() );
RiaLogging::info( QString( "Saved workflow '%1' to %2" ).arg( m_name(), workflowDirectory() ).toStdString() );
return {};
}
//--------------------------------------------------------------------------------------------------
/// Save to a new folder. Python modules next to the original workflow.yaml are copied along.
//--------------------------------------------------------------------------------------------------
std::expected<void, QString> RimWorkflow::saveAs( const QString& directory )
{
if ( !isEditable() ) return std::unexpected( "The workflow is read-only" );
if ( directory.isEmpty() ) return std::unexpected( "No folder given" );
QDir target( directory );
if ( !target.exists() && !QDir().mkpath( directory ) )
return std::unexpected( QString( "Could not create the folder '%1'" ).arg( directory ) );
if ( !m_sourceDirectory.isEmpty() && QDir( m_sourceDirectory ).absolutePath() != target.absolutePath() )
{
if ( auto copied = copyPythonModules( m_sourceDirectory, target.absolutePath() ); !copied )
return std::unexpected( QString( "The workflow was not saved: %1" ).arg( copied.error() ) );
}
auto output = saveToDirectory( target.absolutePath(), false );
if ( !output ) return std::unexpected( output.error() );
m_workflowDirectory = target.absolutePath();
m_sourceDirectory = target.absolutePath();
m_registeredId = "";
updateUiName();
notifyDefinitionChanged();
RiaLogging::info( QString( "Saved workflow '%1' to %2" ).arg( m_name(), target.absolutePath() ).toStdString() );
return {};
}
//--------------------------------------------------------------------------------------------------
/// The temporary folder that live validation writes the YAML of unsaved edits to. Runs use their
/// own folder (see prepareRunSource()), so validation never rewrites a file a run is reading.
//--------------------------------------------------------------------------------------------------
std::expected<QString, QString> RimWorkflow::editDirectory()
{
if ( !m_editDirectory )
{
m_editDirectory = std::make_unique<QTemporaryDir>( QDir::tempPath() + "/resinsight_workflow_edit_XXXXXX" );
if ( !m_editDirectory->isValid() )
{
m_editDirectory.reset();
return std::unexpected( "Could not create a temporary folder for the workflow" );
}
}
return m_editDirectory->path();
}
//--------------------------------------------------------------------------------------------------
/// The definition as sent to `taskmaestro_helper save`
//--------------------------------------------------------------------------------------------------
QByteArray RimWorkflow::definitionJsonForSave() const
{
RimWorkflowDefinition toSave = m_definition;
toSave.inputs = inputValuesForSave();
return QJsonDocument( RimWorkflowDefinitionTools::toJson( toSave ) ).toJson( QJsonDocument::Compact );
}
//--------------------------------------------------------------------------------------------------
/// The arguments of `taskmaestro_helper run` for the current state of the workflow. Unsaved edits
/// are written as workflow.yaml to `runDirectory`, a folder private to the run.
//--------------------------------------------------------------------------------------------------
std::expected<RimWorkflow::RunSource, QString> RimWorkflow::prepareRunSource( const QString& runDirectory )
{
if ( m_source == Source::Registered )
{
if ( m_registeredId().isEmpty() ) return std::unexpected( "The workflow has no registered id" );
return RunSource{ .baseArgs = { "run", "--registered", m_registeredId() } };
}
if ( m_source == Source::YamlDirectory && !isDirty() )
{
if ( workflowDirectory().isEmpty() ) return std::unexpected( "The workflow has no folder" );
return RunSource{ .baseArgs = { "run", workflowDirectory() }, .extraPythonPath = workflowDirectory() };
}
if ( runDirectory.isEmpty() ) return std::unexpected( "No folder to write the edited workflow to" );
auto output = RimWorkflowHelperProcess::runSync( { "save", "--out-dir", runDirectory, "--workflow-only" },
definitionJsonForSave(),
RimWorkflowHelperProcess::defaultTimeoutMs,
m_sourceDirectory );
if ( !output ) return std::unexpected( output.error() );
return RunSource{ .baseArgs = { "run", runDirectory }, .extraPythonPath = m_sourceDirectory };
}
//--------------------------------------------------------------------------------------------------
/// Copies the `*.py` files and the package folders (with `__init__.py`), without `__pycache__`.
/// Files that already exist in the target are kept. Fails on the first file that cannot be copied.
//--------------------------------------------------------------------------------------------------
std::expected<void, QString> RimWorkflow::copyPythonModules( const QString& sourceDirectory, const QString& targetDirectory )
{
const QDir source( sourceDirectory );
QDir target( targetDirectory );
if ( !target.exists() && !QDir().mkpath( targetDirectory ) )
return std::unexpected( QString( "Could not create the folder '%1'" ).arg( targetDirectory ) );
for ( const QString& fileName : source.entryList( { "*.py" }, QDir::Files ) )
{
if ( target.exists( fileName ) ) continue;
if ( !QFile::copy( source.absoluteFilePath( fileName ), target.absoluteFilePath( fileName ) ) )
return std::unexpected( QString( "Could not copy '%1' to '%2'" ).arg( source.absoluteFilePath( fileName ), targetDirectory ) );
}
for ( const QString& folderName : source.entryList( QDir::Dirs | QDir::NoDotAndDotDot ) )
{
if ( folderName == "__pycache__" || !QFileInfo::exists( source.absoluteFilePath( folderName + "/__init__.py" ) ) ) continue;
if ( auto copied = copyPythonModules( source.absoluteFilePath( folderName ), target.absoluteFilePath( folderName ) ); !copied )
return copied;
}
return {};
}
//--------------------------------------------------------------------------------------------------
@@ -256,15 +845,15 @@ QString RimWorkflow::findPythonExecutable()
}
candidates << "python3" << "python";
for ( const QString& cand : candidates )
for ( const QString& candidate : candidates )
{
if ( cand.contains( '/' ) || cand.contains( '\\' ) )
if ( candidate.contains( '/' ) || candidate.contains( '\\' ) )
{
if ( QFileInfo( cand ).isExecutable() ) return cand;
if ( QFileInfo( candidate ).isExecutable() ) return candidate;
}
else if ( !QStandardPaths::findExecutable( cand ).isEmpty() )
else if ( !QStandardPaths::findExecutable( candidate ).isEmpty() )
{
return cand;
return candidate;
}
}
return {};
@@ -15,24 +15,51 @@
// for more details.
//
/////////////////////////////////////////////////////////////////////////////////
#pragma once
#include "RimWorkflowDefinition.h"
#include "cafFilePath.h"
#include "cafPdmChildArrayField.h"
#include "cafPdmField.h"
#include "cafPdmObject.h"
#include <QDateTime>
#include <QJsonObject>
#include <QMap>
#include <expected>
#include <memory>
#include <vector>
class RimWorkflowJob;
class QTemporaryDir;
//==================================================================================================
/// A taskmaestro workflow: a YAML folder, a workflow registered by an installed package, or a new
/// workflow that has not been saved yet. Editable workflows keep their definition in memory with
/// undo/redo until saved.
//==================================================================================================
class RimWorkflow : public caf::PdmObject
{
CAF_PDM_HEADER_INIT;
public:
enum class Source
{
YamlDirectory,
Registered,
Unsaved
};
struct RunSource
{
QStringList baseArgs{};
QString extraPythonPath{};
};
RimWorkflow();
~RimWorkflow() override;
QString name() const;
void setWorkflowDirectory( const QString& directory );
@@ -41,20 +68,103 @@ public:
QString loadError() const;
bool loadFromDirectory( QString* errorMessage = nullptr );
bool loadFromRegistered( const QJsonObject& catalogEntry, QString* errorMessage = nullptr );
void setUnsavedDefinition( const RimWorkflowDefinition& definition,
const QString& sourceDirectory = {},
const QString& targetDirectory = {} );
Source source() const;
QString registeredId() const;
QString sourceDirectory() const;
const RimWorkflowDefinition& definition() const;
bool isEditable() const;
QString readOnlyReason() const;
bool isDirty() const;
bool isLocked() const;
int revision() const;
// Editing
std::expected<void, QString> applyEdit( const std::expected<RimWorkflowDefinition, QString>& result,
const QMap<QString, QString>& renames = {} );
bool canUndo() const;
bool canRedo() const;
// Task renames (old name -> new name) that undo() and redo() would apply
QMap<QString, QString> undoRenames() const;
QMap<QString, QString> redoRenames() const;
void undo();
void redo();
// Saving and running
std::expected<void, QString> save();
QStringList saveInPlaceWarnings() const;
std::expected<void, QString> saveAs( const QString& directory );
std::expected<QString, QString> editDirectory();
QByteArray definitionJsonForSave() const;
std::expected<RunSource, QString> prepareRunSource( const QString& runDirectory );
QJsonObject inputValuesForSave() const;
// Validation of the current revision (static checks plus `taskmaestro workflow describe`)
void setValidationResult( int revision, const std::vector<RimWorkflowIssue>& issues, bool describeSucceeded );
std::vector<RimWorkflowIssue> validationIssues() const;
bool isValidationCurrent() const;
bool lastValidationFailed() const;
std::vector<RimWorkflowJob*> jobs() const;
void addJob( RimWorkflowJob* job );
void syncJobs( const QMap<QString, QString>& renames = {} );
static QString findPythonExecutable();
// Copy the Python modules and packages of a workflow folder. Existing files are kept.
static std::expected<void, QString> copyPythonModules( const QString& sourceDirectory, const QString& targetDirectory );
protected:
void appendMenuItems( caf::CmdFeatureMenuBuilder& menuBuilder ) const override;
void defineUiOrdering( QString uiConfigName, caf::PdmUiOrdering& uiOrdering ) override;
void fieldChangedByUi( const caf::PdmFieldHandle* changedField, const QVariant& oldValue, const QVariant& newValue ) override;
private:
struct UndoEntry
{
RimWorkflowDefinition definition;
QMap<QString, QString> renames;
};
bool setLoadError( const QString& message, QString* errorMessage );
void setDefinition( const RimWorkflowDefinition& definition, Source source );
void definitionChanged( const QMap<QString, QString>& renames );
void notifyDefinitionChanged();
void rebuildGraph();
void updateUiName();
void ensureDefaultJob();
std::expected<QJsonObject, QString> saveToDirectory( const QString& directory, bool backup );
void recordModificationTime();
static QMap<QString, QString> invertedRenames( const QMap<QString, QString>& renames );
private:
caf::PdmField<QString> m_name;
caf::PdmField<QString> m_description;
caf::PdmField<caf::FilePath> m_workflowDirectory;
caf::PdmField<QString> m_registeredId;
caf::PdmField<bool> m_editable;
caf::PdmField<QString> m_readOnlyReason;
caf::PdmField<QString> m_loadError;
caf::PdmChildArrayField<RimWorkflowJob*> m_jobs;
QJsonObject m_graph;
Source m_source = Source::YamlDirectory;
QString m_sourceDirectory;
RimWorkflowDefinition m_definition;
RimWorkflowDefinition m_savedDefinition;
int m_revision = 0;
std::vector<UndoEntry> m_undoStack;
std::vector<UndoEntry> m_redoStack;
std::vector<RimWorkflowIssue> m_validationIssues;
int m_validationRevision = -1;
bool m_describeSucceeded = true;
QDateTime m_loadedModificationTime;
std::unique_ptr<QTemporaryDir> m_editDirectory;
};
@@ -47,7 +47,9 @@ void RimWorkflowArrayBinding::applySchema( const QJsonObject& fieldSchema )
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QString RimWorkflowArrayBinding::toYamlValue() const
QJsonValue RimWorkflowArrayBinding::toJsonValue() const
{
return hasValue() ? m_value() : "null";
if ( !hasValue() ) return QJsonValue::Null;
const QJsonDocument document = QJsonDocument::fromJson( m_value().toUtf8() );
return document.isArray() ? QJsonValue( document.array() ) : QJsonValue( m_value() );
}
@@ -27,8 +27,8 @@ class RimWorkflowArrayBinding : public RimWorkflowFieldBinding
public:
RimWorkflowArrayBinding();
void applySchema( const QJsonObject& fieldSchema ) override;
QString toYamlValue() const override;
void applySchema( const QJsonObject& fieldSchema ) override;
QJsonValue toJsonValue() const override;
caf::PdmFieldHandle* valueField() override { return &m_value; }
@@ -45,8 +45,7 @@ void RimWorkflowBoolBinding::applySchema( const QJsonObject& fieldSchema )
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QString RimWorkflowBoolBinding::toYamlValue() const
QJsonValue RimWorkflowBoolBinding::toJsonValue() const
{
if ( !hasValue() ) return "null";
return m_value() ? "true" : "false";
return hasValue() ? QJsonValue( m_value() ) : QJsonValue( QJsonValue::Null );
}
@@ -27,8 +27,8 @@ class RimWorkflowBoolBinding : public RimWorkflowFieldBinding
public:
RimWorkflowBoolBinding();
void applySchema( const QJsonObject& fieldSchema ) override;
QString toYamlValue() const override;
void applySchema( const QJsonObject& fieldSchema ) override;
QJsonValue toJsonValue() const override;
caf::PdmFieldHandle* valueField() override { return &m_value; }
@@ -19,8 +19,11 @@
#include "RimWorkflowCaseBinding.h"
#include "RimEclipseCase.h"
#include "RimEclipseCaseTools.h"
#include "RimTools.h"
#include <QJsonObject>
CAF_PDM_SOURCE_INIT( RimWorkflowCaseBinding, "WorkflowCaseBinding" );
//--------------------------------------------------------------------------------------------------
@@ -42,10 +45,10 @@ QString RimWorkflowCaseBinding::displayValue() const
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QString RimWorkflowCaseBinding::toYamlValue() const
QJsonValue RimWorkflowCaseBinding::toJsonValue() const
{
if ( m_case() == nullptr ) return "null";
return QString( "{__resinsight_ref__: EclipseCase, case_id: %1}" ).arg( m_case()->caseId() );
if ( m_case() == nullptr ) return QJsonValue::Null;
return QJsonObject{ { "__resinsight_ref__", "EclipseCase" }, { "case_id", m_case()->caseId() } };
}
//--------------------------------------------------------------------------------------------------
@@ -57,3 +60,27 @@ QList<caf::PdmOptionItemInfo> RimWorkflowCaseBinding::calculateValueOptions( con
if ( fieldNeedingOptions == &m_case ) RimTools::eclipseCaseOptionItems( &options );
return options;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
bool RimWorkflowCaseBinding::isObjectReference() const
{
return true;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RimWorkflowCaseBinding::applySchema( const QJsonObject& fieldSchema )
{
RimWorkflowFieldBinding::applySchema( fieldSchema );
const QJsonObject reference = fieldSchema.value( "default" ).toObject();
if ( reference.value( "__resinsight_ref__" ).toString() != "EclipseCase" || !reference.contains( "case_id" ) ) return;
const int caseId = reference.value( "case_id" ).toInt( -1 );
for ( RimEclipseCase* eclipseCase : RimEclipseCaseTools::eclipseCases() )
{
if ( eclipseCase && eclipseCase->caseId() == caseId ) m_case = eclipseCase;
}
}
@@ -31,8 +31,10 @@ class RimWorkflowCaseBinding : public RimWorkflowFieldBinding
public:
RimWorkflowCaseBinding();
QString toYamlValue() const override;
QString displayValue() const override;
QJsonValue toJsonValue() const override;
bool isObjectReference() const override;
void applySchema( const QJsonObject& fieldSchema ) override;
QString displayValue() const override;
caf::PdmFieldHandle* valueField() override { return &m_case; }
@@ -15,13 +15,20 @@
// for more details.
//
/////////////////////////////////////////////////////////////////////////////////
#include "RimWorkflowCollection.h"
#include "RimWorkflowInstalledCollection.h"
#include "RimWorkflowTaskCatalog.h"
#include "RiaLogging.h"
#include "RiaPreferencesSystem.h"
#include "cafCmdFeatureMenuBuilder.h"
#include "cafPdmUiTreeOrdering.h"
#include <QDir>
#include <QFileInfo>
#include <QJsonArray>
CAF_PDM_SOURCE_INIT( RimWorkflowCollection, "WorkflowCollection" );
@@ -33,9 +40,13 @@ RimWorkflowCollection::RimWorkflowCollection()
CAF_PDM_InitObject( "Workflows", ":/Folder.png" );
CAF_PDM_InitFieldNoDefault( &m_items, "Workflows", "" );
rescanWorkflows();
CAF_PDM_InitFieldNoDefault( &m_installed, "Installed", "" );
m_installed = new RimWorkflowInstalledCollection;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
RimWorkflowCollection::~RimWorkflowCollection() = default;
//--------------------------------------------------------------------------------------------------
@@ -47,25 +58,103 @@ QString RimWorkflowCollection::discoveryDirectory()
}
//--------------------------------------------------------------------------------------------------
///
/// Reload the task catalog, the workflow folders and the installed workflows. Workflows that
/// have never been saved are kept. This runs the Python helper, so it is not done in the
/// constructor: projects are also created in console mode and by code generation.
//--------------------------------------------------------------------------------------------------
void RimWorkflowCollection::rescanWorkflows()
{
deleteAllItems();
for ( RimWorkflow* workflow : items() )
{
if ( workflow->source() != RimWorkflow::Source::Unsaved ) deleteItem( workflow );
}
m_installed->deleteAllItems();
if ( !RiaPreferencesSystem::current()->isFeatureEnabled( "workflows" ) ) return;
RimWorkflowTaskCatalog::invalidate();
const RimWorkflowTaskCatalog& catalog = RimWorkflowTaskCatalog::instance();
if ( !catalog.isValid() ) RiaLogging::warning( QString( "Workflow task catalog: %1" ).arg( catalog.loadError() ).toStdString() );
for ( const QString& pluginError : catalog.pluginErrors() )
RiaLogging::warning( QString( "Workflow plugin: %1" ).arg( pluginError ).toStdString() );
QDir dir( discoveryDirectory() );
if ( !dir.exists() ) return;
const QFileInfoList entries = dir.entryInfoList( QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name );
for ( const QFileInfo& entry : entries )
if ( dir.exists() )
{
if ( !QFileInfo( entry.absoluteFilePath() + "/workflow.yaml" ).isFile() ) continue;
const QFileInfoList entries = dir.entryInfoList( QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name );
for ( const QFileInfo& entry : entries )
{
if ( !QFileInfo( entry.absoluteFilePath() + "/workflow.yaml" ).isFile() ) continue;
auto* workflow = new RimWorkflow;
workflow->setWorkflowDirectory( entry.absoluteFilePath() );
workflow->loadFromDirectory();
addItem( workflow );
auto* workflow = new RimWorkflow;
workflow->setWorkflowDirectory( entry.absoluteFilePath() );
workflow->loadFromDirectory();
addItem( workflow );
}
}
for ( const QJsonValue& entry : catalog.registeredWorkflows() )
{
auto* workflow = new RimWorkflow;
workflow->loadFromRegistered( entry.toObject() );
m_installed->addItem( workflow );
}
uiCapability()->updateConnectedEditors();
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
bool RimWorkflowCollection::hasUnsavedChanges() const
{
for ( RimWorkflow* workflow : items() )
{
if ( workflow->isEditable() && workflow->isDirty() ) return true;
}
return false;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
std::vector<RimWorkflow*> RimWorkflowCollection::allWorkflows() const
{
std::vector<RimWorkflow*> workflows = items();
for ( RimWorkflow* workflow : m_installed->items() )
workflows.push_back( workflow );
return workflows;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
RimWorkflow* RimWorkflowCollection::findWorkflowByDirectory( const QString& directory ) const
{
const QString wanted = QDir( directory ).absolutePath();
for ( RimWorkflow* workflow : items() )
{
if ( !workflow->workflowDirectory().isEmpty() && QDir( workflow->workflowDirectory() ).absolutePath() == wanted ) return workflow;
}
return nullptr;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RimWorkflowCollection::appendMenuItems( caf::CmdFeatureMenuBuilder& menuBuilder ) const
{
menuBuilder << "RicNewWorkflowFeature";
menuBuilder << "RicRescanWorkflowsFeature";
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RimWorkflowCollection::defineUiTreeOrdering( caf::PdmUiTreeOrdering& uiTreeOrdering, QString uiConfigName )
{
for ( RimWorkflow* workflow : items() )
uiTreeOrdering.add( workflow );
if ( m_installed->count() > 0 ) uiTreeOrdering.add( m_installed() );
uiTreeOrdering.skipRemainingChildren( true );
}
@@ -15,15 +15,21 @@
// for more details.
//
/////////////////////////////////////////////////////////////////////////////////
#pragma once
#include "RimWorkflow.h"
#include "cafPdmChildField.h"
#include "cafPdmObjectCollection.h"
#include <QString>
class RimWorkflowInstalledCollection;
//==================================================================================================
/// Workflow folders in the discovery folder, new unsaved workflows, and the workflows registered
/// by installed packages (in the "Installed" sub folder)
//==================================================================================================
class RimWorkflowCollection : public caf::PdmObjectCollection<RimWorkflow>
{
CAF_PDM_HEADER_INIT;
@@ -33,6 +39,17 @@ public:
~RimWorkflowCollection() override;
void rescanWorkflows();
bool hasUnsavedChanges() const;
std::vector<RimWorkflow*> allWorkflows() const;
RimWorkflow* findWorkflowByDirectory( const QString& directory ) const;
static QString discoveryDirectory();
protected:
void appendMenuItems( caf::CmdFeatureMenuBuilder& menuBuilder ) const override;
void defineUiTreeOrdering( caf::PdmUiTreeOrdering& uiTreeOrdering, QString uiConfigName = "" ) override;
private:
caf::PdmChildField<RimWorkflowInstalledCollection*> m_installed;
};
@@ -46,7 +46,7 @@ void RimWorkflowDateBinding::applySchema( const QJsonObject& fieldSchema )
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QString RimWorkflowDateBinding::toYamlValue() const
QJsonValue RimWorkflowDateBinding::toJsonValue() const
{
return hasValue() ? yamlQuotedScalar( m_value().toString( Qt::ISODate ) ) : "null";
return hasValue() ? QJsonValue( m_value().toString( Qt::ISODate ) ) : QJsonValue( QJsonValue::Null );
}
@@ -29,8 +29,8 @@ class RimWorkflowDateBinding : public RimWorkflowFieldBinding
public:
RimWorkflowDateBinding();
void applySchema( const QJsonObject& fieldSchema ) override;
QString toYamlValue() const override;
void applySchema( const QJsonObject& fieldSchema ) override;
QJsonValue toJsonValue() const override;
caf::PdmFieldHandle* valueField() override { return &m_value; }
@@ -0,0 +1,147 @@
/////////////////////////////////////////////////////////////////////////////////
//
// 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 "RimWorkflowDefinition.h"
#include "RimWorkflowPortCompatibility.h"
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
bool RimWorkflowDefinitionEdge::isWholeInput() const
{
return input.isEmpty();
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
bool RimWorkflowDefinitionEdge::isWholeOutput() const
{
return output.isEmpty();
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
bool RimWorkflowDefinitionEdge::isCollected() const
{
return collect != Collect::None;
}
//--------------------------------------------------------------------------------------------------
/// Same tasks and ports, ignoring how the value is collected
//--------------------------------------------------------------------------------------------------
bool RimWorkflowDefinitionEdge::sameConnection( const RimWorkflowDefinitionEdge& other ) const
{
return from == other.from && output == other.output && to == other.to && input == other.input;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
const RimWorkflowDefinitionNode* RimWorkflowDefinition::findNode( const QString& nodeName ) const
{
for ( const auto& node : nodes )
{
if ( node.name == nodeName ) return &node;
}
return nullptr;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
RimWorkflowDefinitionNode* RimWorkflowDefinition::findNode( const QString& nodeName )
{
for ( auto& node : nodes )
{
if ( node.name == nodeName ) return &node;
}
return nullptr;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
int RimWorkflowDefinition::nodeIndex( const QString& nodeName ) const
{
for ( size_t i = 0; i < nodes.size(); ++i )
{
if ( nodes[i].name == nodeName ) return static_cast<int>( i );
}
return -1;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QJsonObject RimWorkflowDefinition::taskType( const QString& taskId ) const
{
return taskTypes.value( taskId ).toObject();
}
//--------------------------------------------------------------------------------------------------
/// The task type as seen by the workflow: a mapped task has `dict[str, Output]` as output, and its
/// key and value fields are not inputs
//--------------------------------------------------------------------------------------------------
QJsonObject RimWorkflowDefinition::taskTypeForNode( const QString& nodeName ) const
{
const auto* node = findNode( nodeName );
if ( !node ) return {};
const QJsonObject type = taskType( node->taskId );
if ( type.isEmpty() || !node->map ) return type;
return RimWorkflowPortCompatibility::mappedTaskType( type, *node->map );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
std::vector<RimWorkflowDefinitionEdge> RimWorkflowDefinition::incomingEdges( const QString& nodeName ) const
{
std::vector<RimWorkflowDefinitionEdge> result;
for ( const auto& edge : edges )
{
if ( edge.to == nodeName ) result.push_back( edge );
}
return result;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
std::vector<RimWorkflowDefinitionEdge> RimWorkflowDefinition::outgoingEdges( const QString& nodeName ) const
{
std::vector<RimWorkflowDefinitionEdge> result;
for ( const auto& edge : edges )
{
if ( edge.from == nodeName ) result.push_back( edge );
}
return result;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QStringList RimWorkflowDefinition::nodeNames() const
{
QStringList names;
for ( const auto& node : nodes )
names.append( node.name );
return names;
}
@@ -0,0 +1,151 @@
/////////////////////////////////////////////////////////////////////////////////
//
// 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.
//
/////////////////////////////////////////////////////////////////////////////////
#pragma once
#include <QJsonObject>
#include <QJsonValue>
#include <QString>
#include <QStringList>
#include <optional>
#include <vector>
//==================================================================================================
/// A mapped task (`map:` in YAML) runs once per entry of the job config value `over`, a mapping
/// with string keys. Each key goes to the input field `keyAs` and each value to `valueAs`.
/// The output is `dict[str, Output]` keyed like `over`.
//==================================================================================================
struct RimWorkflowTaskMap
{
QString over{};
QString keyAs{};
QString valueAs{};
QString errorMode = "fail_fast";
bool operator==( const RimWorkflowTaskMap& other ) const = default;
};
//==================================================================================================
/// One task instance in a workflow
//==================================================================================================
struct RimWorkflowDefinitionNode
{
QString name{};
QString taskId{};
QStringList configFields{};
// Fields the user wants to configure even when they are optional or cannot be derived
QStringList explicitConfigFields{};
std::optional<RimWorkflowTaskMap> map{};
// Keys of the YAML entry the editor does not model (nested workflow); kept for read-only display
QJsonObject extra{};
bool operator==( const RimWorkflowDefinitionNode& other ) const = default;
};
//==================================================================================================
/// A connection between two tasks. An empty output or input means the whole model.
/// Several edges can collect outputs into one `list[T]` or `dict[str, T]` input (`collect:` in
/// YAML). List members are ordered like the edges; dict members have a key.
//==================================================================================================
struct RimWorkflowDefinitionEdge
{
enum class Collect
{
None,
List,
Dict
};
QString from{};
QString output{};
QString to{};
QString input{};
Collect collect = Collect::None;
QString key{};
bool isWholeInput() const;
bool isWholeOutput() const;
bool isCollected() const;
bool sameConnection( const RimWorkflowDefinitionEdge& other ) const;
bool operator==( const RimWorkflowDefinitionEdge& other ) const = default;
};
//==================================================================================================
/// A problem found by static validation or by `taskmaestro workflow describe`
//==================================================================================================
struct RimWorkflowIssue
{
enum class Severity
{
Error,
Warning
};
QString task{};
QString field{};
Severity severity = Severity::Error;
QString message{};
bool operator==( const RimWorkflowIssue& other ) const = default;
};
//==================================================================================================
/// Editable model of a taskmaestro workflow, exchanged as JSON with `rips.taskmaestro_helper`.
/// A plain value type: copies are cheap enough to serve as undo snapshots.
//==================================================================================================
struct RimWorkflowDefinition
{
int format = 1;
QString name;
QString resultTask;
QString headerComment;
QJsonObject passthrough;
QJsonObject source;
std::vector<RimWorkflowDefinitionNode> nodes;
std::vector<RimWorkflowDefinitionEdge> edges;
// Task instance name -> { field: value }
QJsonObject inputs;
// Task id -> descriptor { id, name, python_type, description, input_schema, output_schema }
QJsonObject taskTypes;
bool editable = true;
QStringList readOnlyReasons;
QStringList warnings;
QJsonObject describe;
QJsonObject describeError;
const RimWorkflowDefinitionNode* findNode( const QString& nodeName ) const;
RimWorkflowDefinitionNode* findNode( const QString& nodeName );
int nodeIndex( const QString& nodeName ) const;
QJsonObject taskType( const QString& taskId ) const;
QJsonObject taskTypeForNode( const QString& nodeName ) const;
std::vector<RimWorkflowDefinitionEdge> incomingEdges( const QString& nodeName ) const;
std::vector<RimWorkflowDefinitionEdge> outgoingEdges( const QString& nodeName ) const;
QStringList nodeNames() const;
bool operator==( const RimWorkflowDefinition& other ) const = default;
};
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,89 @@
/////////////////////////////////////////////////////////////////////////////////
//
// 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.
//
/////////////////////////////////////////////////////////////////////////////////
#pragma once
#include "RimWorkflowDefinition.h"
#include <QJsonObject>
#include <QMap>
#include <QString>
#include <QStringList>
#include <expected>
#include <optional>
#include <vector>
class RimWorkflowTaskCatalog;
//==================================================================================================
/// Pure functions on RimWorkflowDefinition: JSON conversion, config derivation, edit operations,
/// static validation and the graph JSON shown by RiuWorkflowGraphView.
/// Edit operations return the new definition, or an error message for the user.
//==================================================================================================
namespace RimWorkflowDefinitionTools
{
using EditResult = std::expected<RimWorkflowDefinition, QString>;
constexpr const char* connectTaskId = "resinsight.connect";
// Conversion
std::expected<RimWorkflowDefinition, QString> fromJson( const QJsonObject& json );
QJsonObject toJson( const RimWorkflowDefinition& definition );
RimWorkflowDefinition createEmpty( const QString& name );
RimWorkflowDefinition editableCopy( const RimWorkflowDefinition& definition, const QString& name );
// Names
bool isIdentifier( const QString& name );
QString identifierFrom( const QString& text );
QString uniqueInstanceName( const RimWorkflowDefinition& definition, const QString& baseName );
std::expected<void, QString>
validateInstanceName( const RimWorkflowDefinition& definition, const QString& name, const QString& currentName = {} );
QStringList sinkTasks( const RimWorkflowDefinition& definition );
QString effectiveResultTask( const RimWorkflowDefinition& definition );
QStringList structuralReadOnlyReasons( const RimWorkflowDefinition& definition );
// Config fields
QStringList coveredInputFields( const RimWorkflowDefinition& definition, const QString& nodeName );
QStringList deriveConfigFields( const RimWorkflowDefinition& definition, const QString& nodeName );
RimWorkflowDefinition normalize( RimWorkflowDefinition definition );
// Edit operations
EditResult addTask( const RimWorkflowDefinition& definition,
const RimWorkflowTaskCatalog& catalog,
const QString& taskId,
QString* addedName = nullptr );
EditResult removeTask( const RimWorkflowDefinition& definition, const QString& nodeName );
EditResult connect( const RimWorkflowDefinition& definition, const RimWorkflowDefinitionEdge& edge );
EditResult disconnect( const RimWorkflowDefinition& definition, const RimWorkflowDefinitionEdge& edge );
EditResult setCollectKey( const RimWorkflowDefinition& definition, const RimWorkflowDefinitionEdge& edge, const QString& key );
EditResult moveCollectMember( const RimWorkflowDefinition& definition, const RimWorkflowDefinitionEdge& edge, int delta );
EditResult setTaskMap( const RimWorkflowDefinition& definition, const QString& nodeName, const std::optional<RimWorkflowTaskMap>& map );
std::expected<void, QString> checkTaskMap( const RimWorkflowDefinition& definition, const QString& nodeName, const RimWorkflowTaskMap& map );
QJsonObject mapOverSchema( const RimWorkflowDefinition& definition, const QString& nodeName );
EditResult renameTask( const RimWorkflowDefinition& definition, const QString& oldName, const QString& newName );
EditResult renameWorkflow( const RimWorkflowDefinition& definition, const QString& newName );
EditResult setResultTask( const RimWorkflowDefinition& definition, const QString& nodeName );
EditResult setOptionalInputConfigured( const RimWorkflowDefinition& definition, const QString& nodeName, const QString& field, bool configured );
RimWorkflowDefinition
autoWireResInsightInputs( const RimWorkflowDefinition& definition, const RimWorkflowTaskCatalog& catalog, const QString& nodeName );
// Validation and display
std::vector<RimWorkflowIssue> validate( const RimWorkflowDefinition& definition );
QJsonObject graphFromDefinition( const RimWorkflowDefinition& definition, bool editMode, const std::vector<RimWorkflowIssue>& extraIssues = {} );
} // namespace RimWorkflowDefinitionTools
@@ -18,157 +18,15 @@
#include "RimWorkflowDescribeTools.h"
#include "RimWorkflowPortCompatibility.h"
#include "RimWorkflowSchemaTools.h"
#include <QJsonArray>
#include <QJsonValue>
#include <QMap>
#include <QStringList>
namespace
{
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QJsonObject resolveRef( const QString& ref, const QJsonObject& rootSchema )
{
const QString prefix = "#/$defs/";
if ( !ref.startsWith( prefix ) ) return {};
return rootSchema.value( "$defs" ).toObject().value( ref.mid( prefix.size() ) ).toObject();
}
//--------------------------------------------------------------------------------------------------
/// Follow $ref and unwrap single-item allOf and Optional (anyOf with null) to the underlying type
//--------------------------------------------------------------------------------------------------
QJsonObject underlyingSchema( QJsonObject schema, const QJsonObject& rootSchema )
{
constexpr int maxDepth = 16;
for ( int depth = 0; depth < maxDepth; ++depth )
{
if ( schema.contains( "$ref" ) )
{
schema = resolveRef( schema.value( "$ref" ).toString(), rootSchema );
continue;
}
const QJsonArray allOf = schema.value( "allOf" ).toArray();
if ( allOf.size() == 1 )
{
schema = allOf.first().toObject();
continue;
}
if ( schema.value( "anyOf" ).isArray() )
{
QJsonArray nonNull;
for ( const QJsonValue& arm : schema.value( "anyOf" ).toArray() )
{
if ( arm.toObject().value( "type" ).toString() != "null" ) nonNull.append( arm );
}
if ( nonNull.size() == 1 )
{
schema = nonNull.first().toObject();
continue;
}
}
break;
}
return schema;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
bool isOpaque( const QJsonObject& schema, const QJsonObject& rootSchema )
{
return underlyingSchema( schema, rootSchema ).value( "x-taskmaestro-opaque" ).toBool( false );
}
//--------------------------------------------------------------------------------------------------
/// Python type of a runtime-only object, either bare or wrapped in an ObjectModel `value` field
//--------------------------------------------------------------------------------------------------
QString objectPythonType( const QJsonObject& typeSchema, const QJsonObject& rootSchema )
{
if ( typeSchema.contains( "x-taskmaestro-python-type" ) ) return typeSchema.value( "x-taskmaestro-python-type" ).toString();
const QJsonObject valueSchema = typeSchema.value( "properties" ).toObject().value( "value" ).toObject();
if ( valueSchema.isEmpty() ) return {};
return underlyingSchema( valueSchema, rootSchema ).value( "x-taskmaestro-python-type" ).toString();
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QString jsonType( const QJsonObject& typeSchema )
{
const QString type = typeSchema.value( "type" ).toString();
if ( type == "boolean" || type == "integer" || type == "number" || type == "array" ) return type;
return "string";
}
//--------------------------------------------------------------------------------------------------
/// taskmaestro_resinsight.models.Vec3 is a plain pydantic model (x, y, z numbers), so it has no
/// x-taskmaestro-python-type tag; recognize it structurally instead.
//--------------------------------------------------------------------------------------------------
bool isVec3Schema( const QJsonObject& typeSchema )
{
if ( typeSchema.value( "type" ).toString() != "object" ) return false;
const QJsonObject properties = typeSchema.value( "properties" ).toObject();
if ( properties.size() != 3 ) return false;
for ( const QString& component : QStringList{ "x", "y", "z" } )
{
if ( properties.value( component ).toObject().value( "type" ).toString() != "number" ) return false;
}
return true;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QJsonObject configFieldSchema( const QString& fieldName,
const QJsonObject& inputSchema,
const QStringList& requiredFields,
const QJsonObject& configValues )
{
QJsonObject entry{ { "name", fieldName } };
const QJsonObject properties = inputSchema.value( "properties" ).toObject();
if ( !properties.contains( fieldName ) )
{
entry["type"] = "string";
entry["required"] = true;
entry["error"] = "field not found in input model";
return entry;
}
const QJsonObject property = properties.value( fieldName ).toObject();
const QJsonObject typeSchema = underlyingSchema( property, inputSchema );
QString resinsightType = RimWorkflowDescribeTools::resinsightTypeFromPythonType( objectPythonType( typeSchema, inputSchema ) );
if ( resinsightType.isEmpty() && isVec3Schema( typeSchema ) ) resinsightType = "Vec3";
entry["type"] = resinsightType.isEmpty() ? jsonType( typeSchema ) : "object";
entry["required"] = requiredFields.contains( fieldName );
const QString description = property.value( "description" ).toString();
if ( !description.isEmpty() ) entry["description"] = description;
// Prefer the value already in input.yaml; fall back to the model default
if ( configValues.contains( fieldName ) )
entry["default"] = configValues.value( fieldName );
else if ( property.contains( "default" ) )
entry["default"] = property.value( "default" );
const QString format = typeSchema.value( "format" ).toString();
if ( !format.isEmpty() ) entry["format"] = format;
if ( !resinsightType.isEmpty() ) entry["resinsight_type"] = resinsightType;
return entry;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void appendEdge( QJsonArray& edges, const QJsonObject& ref, const QString& to, const QString& input )
{
const QString from = ref.value( "task" ).toString();
@@ -236,7 +94,7 @@ QJsonObject taskFromDescribe( const QJsonObject& task )
const QJsonObject outputProperties = outputSchema.value( "properties" ).toObject();
for ( auto it = outputProperties.begin(); it != outputProperties.end(); ++it )
{
if ( it.key() == "value" && isOpaque( it.value().toObject(), outputSchema ) ) continue;
if ( it.key() == "value" && RimWorkflowSchemaTools::isOpaque( it.value().toObject(), outputSchema ) ) continue;
outputs.append( it.key() );
}
@@ -251,13 +109,17 @@ QJsonObject taskFromDescribe( const QJsonObject& task )
{
const QString fieldName = value.toString();
if ( fieldName.isEmpty() ) continue;
configFields.append( configFieldSchema( fieldName, inputSchema, requiredFields, configValues ) );
configFields.append( RimWorkflowSchemaTools::configFieldSchema( fieldName, inputSchema, requiredFields, configValues ) );
}
return QJsonObject{ { "name", task.value( "name" ).toString() },
{ "inputs", inputs },
{ "outputs", outputs },
{ "config_fields", configFields } };
{ "config_fields", configFields },
{ "input_types", RimWorkflowPortCompatibility::portTypes( RimWorkflowPortCompatibility::inputPorts( task ) ) },
{ "output_types", RimWorkflowPortCompatibility::portTypes( RimWorkflowPortCompatibility::outputPorts( task ) ) },
{ "input_icons", RimWorkflowPortCompatibility::portIcons( RimWorkflowPortCompatibility::inputPorts( task ) ) },
{ "output_icons", RimWorkflowPortCompatibility::portIcons( RimWorkflowPortCompatibility::outputPorts( task ) ) } };
}
} // namespace
@@ -304,17 +166,5 @@ QString RimWorkflowDescribeTools::errorFromDescribe( const QJsonObject& describe
//--------------------------------------------------------------------------------------------------
QString RimWorkflowDescribeTools::resinsightTypeFromPythonType( const QString& pythonType )
{
const int separator = pythonType.lastIndexOf( '.' );
if ( separator < 0 ) return {};
const QString module = pythonType.left( separator );
if ( module != "rips" && !module.startsWith( "rips." ) ) return {};
static const QMap<QString, QString> typeMap = { { "Case", "EclipseCase" },
{ "Reservoir", "EclipseCase" },
{ "EclipseCase", "EclipseCase" },
{ "WellPath", "WellPath" },
{ "View", "View" },
{ "EclipseView", "View" } };
return typeMap.value( pythonType.mid( separator + 1 ) );
return RimWorkflowSchemaTools::resinsightTypeFromPythonType( pythonType );
}
@@ -121,10 +121,9 @@ bool RimWorkflowFieldBinding::hasValue() const
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QString RimWorkflowFieldBinding::yamlQuotedScalar( const QString& value )
bool RimWorkflowFieldBinding::isObjectReference() const
{
const QByteArray encoded = QJsonDocument( QJsonArray{ value } ).toJson( QJsonDocument::Compact );
return QString::fromUtf8( encoded.mid( 1, encoded.size() - 2 ) );
return false;
}
//--------------------------------------------------------------------------------------------------
@@ -21,6 +21,8 @@
#include "cafPdmField.h"
#include "cafPdmObject.h"
#include <QJsonValue>
class QJsonObject;
class RimWorkflowFieldBinding : public caf::PdmObject
@@ -35,17 +37,17 @@ public:
void setDescription( const QString& description );
void setRequired( bool required );
virtual void applySchema( const QJsonObject& fieldSchema );
virtual QString toYamlValue() const = 0;
virtual QString displayValue() const;
virtual void applySchema( const QJsonObject& fieldSchema );
virtual QJsonValue toJsonValue() const = 0;
virtual bool isObjectReference() const;
virtual QString displayValue() const;
virtual caf::PdmFieldHandle* valueField() = 0;
protected:
void fieldChangedByUi( const caf::PdmFieldHandle* changedField, const QVariant& oldValue, const QVariant& newValue ) override;
bool hasValue() const;
static QString yamlQuotedScalar( const QString& value );
bool hasValue() const;
caf::PdmField<QString> m_fieldName;
caf::PdmField<QString> m_description;
@@ -63,7 +63,7 @@ QString RimWorkflowFilePathBinding::displayValue() const
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QString RimWorkflowFilePathBinding::toYamlValue() const
QJsonValue RimWorkflowFilePathBinding::toJsonValue() const
{
return hasValue() ? yamlQuotedScalar( m_value().path() ) : "null";
return hasValue() ? QJsonValue( m_value().path() ) : QJsonValue( QJsonValue::Null );
}
@@ -29,9 +29,9 @@ class RimWorkflowFilePathBinding : public RimWorkflowFieldBinding
public:
RimWorkflowFilePathBinding();
void applySchema( const QJsonObject& fieldSchema ) override;
QString toYamlValue() const override;
QString displayValue() const override;
void applySchema( const QJsonObject& fieldSchema ) override;
QJsonValue toJsonValue() const override;
QString displayValue() const override;
caf::PdmFieldHandle* valueField() override { return &m_value; }
@@ -45,7 +45,7 @@ void RimWorkflowFloatBinding::applySchema( const QJsonObject& fieldSchema )
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QString RimWorkflowFloatBinding::toYamlValue() const
QJsonValue RimWorkflowFloatBinding::toJsonValue() const
{
return hasValue() ? QString::number( m_value(), 'g', 17 ) : "null";
return hasValue() ? QJsonValue( m_value() ) : QJsonValue( QJsonValue::Null );
}
@@ -27,8 +27,8 @@ class RimWorkflowFloatBinding : public RimWorkflowFieldBinding
public:
RimWorkflowFloatBinding();
void applySchema( const QJsonObject& fieldSchema ) override;
QString toYamlValue() const override;
void applySchema( const QJsonObject& fieldSchema ) override;
QJsonValue toJsonValue() const override;
caf::PdmFieldHandle* valueField() override { return &m_value; }
@@ -0,0 +1,131 @@
/////////////////////////////////////////////////////////////////////////////////
//
// 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 "RimWorkflowHelperProcess.h"
#include "RimWorkflow.h"
#include <QDir>
#include <QJsonDocument>
#include <QProcess>
namespace
{
constexpr int startTimeoutMs = 10000;
} // namespace
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
std::expected<QJsonObject, QString>
RimWorkflowHelperProcess::runSync( const QStringList& args, const QByteArray& stdinData, int timeoutMs, const QString& extraPythonPath )
{
const QString python = RimWorkflow::findPythonExecutable();
if ( python.isEmpty() ) return std::unexpected( "No usable Python interpreter found" );
QProcess process;
process.setProcessEnvironment( environment( extraPythonPath ) );
process.start( python, helperArguments( args ) );
if ( !process.waitForStarted( startTimeoutMs ) ) return std::unexpected( QString( "Could not launch '%1'" ).arg( python ) );
if ( !stdinData.isEmpty() ) process.write( stdinData );
process.closeWriteChannel();
if ( !process.waitForFinished( timeoutMs ) )
{
process.kill();
process.waitForFinished( startTimeoutMs );
return std::unexpected( QString( "The workflow helper timed out (%1)" ).arg( args.value( 0 ) ) );
}
const int exitCode = process.exitStatus() == QProcess::NormalExit ? process.exitCode() : -1;
return parseOutput( exitCode, process.readAllStandardOutput(), process.readAllStandardError() );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QStringList RimWorkflowHelperProcess::helperArguments( const QStringList& args )
{
return QStringList{ "-m", "rips.taskmaestro_helper" } + args;
}
//--------------------------------------------------------------------------------------------------
/// The process environment, with an extra folder in front of PYTHONPATH (to import workflow-local modules)
//--------------------------------------------------------------------------------------------------
QProcessEnvironment RimWorkflowHelperProcess::environment( const QString& extraPythonPath )
{
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
if ( extraPythonPath.isEmpty() ) return env;
const QString existing = env.value( "PYTHONPATH" );
const QString path = QDir::toNativeSeparators( extraPythonPath );
env.insert( "PYTHONPATH", existing.isEmpty() ? path : path + QDir::listSeparator() + existing );
return env;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
std::expected<QJsonObject, QString>
RimWorkflowHelperProcess::parseOutput( int exitCode, const QByteArray& standardOutput, const QByteArray& standardError )
{
if ( const auto invalid = invalidResult( standardOutput ) )
return std::unexpected( errorMessage( invalid->value( "error" ).toObject() ) );
QJsonParseError parseError{};
const QJsonDocument document = QJsonDocument::fromJson( standardOutput.trimmed(), &parseError );
if ( parseError.error == QJsonParseError::NoError && document.isObject() && exitCode == 0 ) return document.object();
QString message = QString::fromUtf8( standardError ).trimmed();
if ( message.isEmpty() ) message = QString::fromUtf8( standardOutput ).trimmed();
// The last lines of a traceback hold the exception
const QStringList lines = message.split( '\n', Qt::SkipEmptyParts );
if ( lines.size() > 6 ) message = lines.mid( lines.size() - 6 ).join( '\n' );
if ( message.isEmpty() ) message = QString( "The workflow helper failed with exit code %1" ).arg( exitCode );
return std::unexpected( message );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
std::optional<QJsonObject> RimWorkflowHelperProcess::invalidResult( const QByteArray& standardOutput )
{
QJsonParseError parseError{};
const QJsonDocument document = QJsonDocument::fromJson( standardOutput.trimmed(), &parseError );
if ( parseError.error != QJsonParseError::NoError || !document.isObject() ) return std::nullopt;
if ( document.object().value( "status" ).toString() != "invalid" ) return std::nullopt;
return document.object();
}
//--------------------------------------------------------------------------------------------------
/// Message for an error object { message, task, field }
//--------------------------------------------------------------------------------------------------
QString RimWorkflowHelperProcess::errorMessage( const QJsonObject& error )
{
QString message = error.value( "message" ).toString();
if ( message.isEmpty() ) message = "Workflow configuration is invalid";
QStringList details;
const QString task = error.value( "task" ).toString();
if ( !task.isEmpty() && !message.contains( task ) ) details.append( QString( "task '%1'" ).arg( task ) );
const QString field = error.value( "field" ).toString();
if ( !field.isEmpty() && !message.contains( field ) ) details.append( QString( "field '%1'" ).arg( field ) );
if ( !details.isEmpty() ) message += " (" + details.join( ", " ) + ")";
return message;
}
@@ -0,0 +1,48 @@
/////////////////////////////////////////////////////////////////////////////////
//
// 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.
//
/////////////////////////////////////////////////////////////////////////////////
#pragma once
#include <QByteArray>
#include <QJsonObject>
#include <QProcessEnvironment>
#include <QString>
#include <QStringList>
#include <expected>
#include <optional>
//==================================================================================================
/// Runs `python -m rips.taskmaestro_helper <args>`, which prints exactly one JSON document
//==================================================================================================
namespace RimWorkflowHelperProcess
{
constexpr int defaultTimeoutMs = 60000;
std::expected<QJsonObject, QString>
runSync( const QStringList& args, const QByteArray& stdinData = {}, int timeoutMs = defaultTimeoutMs, const QString& extraPythonPath = {} );
QStringList helperArguments( const QStringList& args );
QProcessEnvironment environment( const QString& extraPythonPath );
std::expected<QJsonObject, QString> parseOutput( int exitCode, const QByteArray& standardOutput, const QByteArray& standardError );
// The `{"status": "invalid", "error": {...}}` document printed on stdout, if any. parseOutput()
// reduces it to a message; this keeps the task and field the error refers to.
std::optional<QJsonObject> invalidResult( const QByteArray& standardOutput );
QString errorMessage( const QJsonObject& error );
} // namespace RimWorkflowHelperProcess
@@ -0,0 +1,29 @@
/////////////////////////////////////////////////////////////////////////////////
//
// 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 "RimWorkflowInstalledCollection.h"
CAF_PDM_SOURCE_INIT( RimWorkflowInstalledCollection, "WorkflowInstalledCollection" );
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
RimWorkflowInstalledCollection::RimWorkflowInstalledCollection()
{
CAF_PDM_InitObject( "Installed", ":/Folder.png" );
CAF_PDM_InitFieldNoDefault( &m_items, "Workflows", "" );
}
@@ -0,0 +1,33 @@
/////////////////////////////////////////////////////////////////////////////////
//
// 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.
//
/////////////////////////////////////////////////////////////////////////////////
#pragma once
#include "RimWorkflow.h"
#include "cafPdmObjectCollection.h"
//==================================================================================================
/// Workflows registered by installed Python packages (read-only)
//==================================================================================================
class RimWorkflowInstalledCollection : public caf::PdmObjectCollection<RimWorkflow>
{
CAF_PDM_HEADER_INIT;
public:
RimWorkflowInstalledCollection();
};
@@ -45,7 +45,7 @@ void RimWorkflowIntBinding::applySchema( const QJsonObject& fieldSchema )
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QString RimWorkflowIntBinding::toYamlValue() const
QJsonValue RimWorkflowIntBinding::toJsonValue() const
{
return hasValue() ? QString::number( m_value() ) : "null";
return hasValue() ? QJsonValue( m_value() ) : QJsonValue( QJsonValue::Null );
}
@@ -27,8 +27,8 @@ class RimWorkflowIntBinding : public RimWorkflowFieldBinding
public:
RimWorkflowIntBinding();
void applySchema( const QJsonObject& fieldSchema ) override;
QString toYamlValue() const override;
void applySchema( const QJsonObject& fieldSchema ) override;
QJsonValue toJsonValue() const override;
caf::PdmFieldHandle* valueField() override { return &m_value; }
@@ -19,6 +19,7 @@
#include "RimWorkflowJob.h"
#include "RimWorkflow.h"
#include "RimWorkflowHelperProcess.h"
#include "RiaApplication.h"
#include "RiaLogging.h"
@@ -34,8 +35,7 @@
#include <QDir>
#include <QFile>
#include <QJsonArray>
#include <QJsonObject>
#include <QJsonDocument>
#include <QProcessEnvironment>
#include <QUuid>
@@ -87,7 +87,7 @@ void RimWorkflowJob::defineUiOrdering( QString uiConfigName, caf::PdmUiOrdering&
for ( RimWorkflowTaskInput* task : m_taskInputs.childrenByType() )
{
if ( !task ) continue;
if ( !task || task->count() == 0 ) continue;
caf::PdmUiGroup* group = uiOrdering.addNewGroup( task->taskName() );
for ( RimWorkflowFieldBinding* b : task->items() )
{
@@ -212,6 +212,29 @@ void RimWorkflowJob::updateTaskState( const QString& runId, const QString& taskN
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QMap<QString, QMap<QString, QString>> RimWorkflowJob::itemStates() const
{
return m_itemStates;
}
QMap<QString, QMap<QString, QString>> RimWorkflowJob::itemErrors() const
{
return m_itemErrors;
}
void RimWorkflowJob::updateItemState( const QString& runId, const QString& taskName, const QString& item, const QString& state, const QString& error )
{
if ( runId != m_runId || !m_taskStates.contains( taskName ) ) return;
if ( state != "running" && state != "completed" && state != "failed" ) return;
m_itemStates[taskName][item] = state;
if ( state == "failed" )
m_itemErrors[taskName][item] = error;
else if ( m_itemErrors.contains( taskName ) )
m_itemErrors[taskName].remove( item );
if ( auto* window = RiuMainWindow::instance() ) window->workflowJobStateChanged( this );
}
void RimWorkflowJob::finishRun( const QString& runId, bool succeeded, bool cancelled )
{
if ( runId != m_runId ) return;
@@ -226,7 +249,7 @@ void RimWorkflowJob::finishRun( const QString& runId, bool succeeded, bool cance
if ( cancelled )
m_runStatus = "Cancelled";
else if ( succeeded )
m_runStatus = "Completed";
m_runStatus.clear();
else if ( !m_runStatus.startsWith( "Failed:" ) )
m_runStatus = "Failed (see workflow log)";
m_activeTask.clear();
@@ -244,17 +267,132 @@ void RimWorkflowJob::cancelJob()
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QString RimWorkflowJob::writeInputYaml( const QString& path ) const
RimWorkflowTaskInput* RimWorkflowJob::taskInput( const QString& taskName ) const
{
QString body;
for ( RimWorkflowTaskInput* t : m_taskInputs.childrenByType() )
for ( RimWorkflowTaskInput* input : taskInputs() )
{
if ( t ) body += t->toTaskYamlBlock();
if ( input->taskName() == taskName ) return input;
}
return nullptr;
}
//--------------------------------------------------------------------------------------------------
/// Make the task inputs follow the tasks of the workflow graph. Inputs of renamed tasks
/// (`renames` maps old to new names) keep their values; values of removed fields are kept aside
/// and restored if the field comes back.
//--------------------------------------------------------------------------------------------------
void RimWorkflowJob::syncTaskInputs( const QJsonArray& graphTasks, const QMap<QString, QString>& renames )
{
// Detached values are stored as "task/field/binding"
QMap<QString, QJsonValue> detached;
for ( auto it = m_detachedValues.begin(); it != m_detachedValues.end(); ++it )
{
const QString task = it.key().section( '/', 0, 0 );
const QString rest = it.key().section( '/', 1 );
detached.insert( renames.value( task, task ) + "/" + rest, it.value() );
}
std::vector<RimWorkflowTaskInput*> existing = taskInputs();
m_taskInputs.clearWithoutDelete();
auto takeExisting = [&existing, &renames]( const QString& name ) -> RimWorkflowTaskInput*
{
for ( auto it = existing.begin(); it != existing.end(); ++it )
{
const QString currentName = renames.value( ( *it )->taskName(), ( *it )->taskName() );
if ( currentName == name )
{
RimWorkflowTaskInput* input = *it;
existing.erase( it );
return input;
}
}
return nullptr;
};
for ( const QJsonValue& value : graphTasks )
{
const QJsonObject task = value.toObject();
const QString name = task.value( "name" ).toString();
RimWorkflowTaskInput* input = takeExisting( name );
if ( !input ) input = new RimWorkflowTaskInput;
input->setTaskName( name );
input->setTaskInfo( task.value( "task_id" ).toString(), task.value( "description" ).toString() );
QMap<QString, QJsonValue> taskDetached;
const QString prefix = name + "/";
for ( auto it = detached.begin(); it != detached.end(); )
{
if ( it.key().startsWith( prefix ) )
{
taskDetached.insert( it.key().mid( prefix.size() ), it.value() );
it = detached.erase( it );
}
else
{
++it;
}
}
input->syncFromSchema( task.value( "config_fields" ).toArray(), taskDetached );
for ( auto it = taskDetached.begin(); it != taskDetached.end(); ++it )
detached.insert( prefix + it.key(), it.value() );
m_taskInputs.push_back( input );
}
// Inputs of removed tasks are kept aside, in case the removal is undone
for ( RimWorkflowTaskInput* removed : existing )
{
const QString name = renames.value( removed->taskName(), removed->taskName() );
for ( RimWorkflowFieldBinding* binding : removed->items() )
{
const QJsonValue value = binding->toJsonValue();
if ( !value.isNull() )
detached.insert( name + "/" + RimWorkflowTaskInput::detachedKey( binding->fieldName(), binding->classKeyword() ), value );
}
delete removed;
}
m_detachedValues = detached;
uiCapability()->updateConnectedEditors();
}
//--------------------------------------------------------------------------------------------------
/// The values used to run the job, including references to ResInsight objects
//--------------------------------------------------------------------------------------------------
QJsonObject RimWorkflowJob::inputValues() const
{
QJsonObject values;
for ( RimWorkflowTaskInput* input : taskInputs() )
{
if ( input->count() > 0 ) values.insert( input->taskName(), input->jsonValues() );
}
return values;
}
//--------------------------------------------------------------------------------------------------
/// Values that can be saved to input.yaml
//--------------------------------------------------------------------------------------------------
QJsonObject RimWorkflowJob::literalInputValues() const
{
QJsonObject values;
for ( RimWorkflowTaskInput* input : taskInputs() )
{
if ( input->count() > 0 ) values.insert( input->taskName(), input->literalValues() );
}
return values;
}
//--------------------------------------------------------------------------------------------------
/// Write the input values as JSON, which is also valid YAML
//--------------------------------------------------------------------------------------------------
QString RimWorkflowJob::writeInputYaml( const QString& path ) const
{
QFile out( path );
if ( !out.open( QIODevice::WriteOnly | QIODevice::Truncate ) ) return {};
out.write( body.toUtf8() );
out.write( QJsonDocument( inputValues() ).toJson( QJsonDocument::Indented ) );
out.close();
return path;
}
@@ -277,9 +415,6 @@ void RimWorkflowJob::runJob()
return;
}
const QString dir = workflow->workflowDirectory();
if ( dir.isEmpty() ) return;
auto port = RiaApplication::instance()->activeGrpcPortNumber();
if ( !port.has_value() )
{
@@ -301,6 +436,13 @@ void RimWorkflowJob::runJob()
RiaLogging::warning( "Cannot create temp dir for workflow run." );
return;
}
auto runSource = workflow->prepareRunSource( tmp.absoluteFilePath( runDir ) );
if ( !runSource )
{
RiaLogging::warning( QString( "Cannot run workflow: %1" ).arg( runSource.error() ).toStdString() );
return;
}
QString inputPath = tmp.absoluteFilePath( runDir + "/input.yaml" );
if ( writeInputYaml( inputPath ).isEmpty() )
{
@@ -311,6 +453,8 @@ void RimWorkflowJob::runJob()
m_runId = QUuid::createUuid().toString( QUuid::WithoutBraces );
m_taskStates.clear();
m_taskErrors.clear();
m_itemStates.clear();
m_itemErrors.clear();
for ( const QJsonValue& value : workflow->graph().value( "tasks" ).toArray() )
{
const QString taskName = value.toObject().value( "name" ).toString();
@@ -320,9 +464,10 @@ void RimWorkflowJob::runJob()
m_runStatus = "Running";
if ( auto* window = RiuMainWindow::instance() ) window->workflowJobStateChanged( this );
QStringList args{ "-m", "rips.taskmaestro_helper", "run", dir, "--input", inputPath, "--grpc-port", QString::number( port.value() ), "--run-id", m_runId };
const QStringList args = RimWorkflowHelperProcess::helperArguments(
runSource->baseArgs + QStringList{ "--input", inputPath, "--grpc-port", QString::number( port.value() ), "--run-id", m_runId } );
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
QProcessEnvironment env = RimWorkflowHelperProcess::environment( runSource->extraPythonPath );
const QString label = workflow->uiName() + " / " + m_name();
m_runner = new RiuWorkflowJobRunner( label, RiuMainWindow::instance() );
@@ -335,6 +480,14 @@ void RimWorkflowJob::runJob()
{
if ( safeJob && eventRunId == runId ) safeJob->updateTaskState( runId, taskName, state, error );
} );
QObject::connect( m_runner,
&RiuWorkflowJobRunner::mapItemStateChanged,
m_runner,
[safeJob,
runId]( const QString& eventRunId, const QString& taskName, const QString& item, const QString& state, const QString& error )
{
if ( safeJob && eventRunId == runId ) safeJob->updateItemState( runId, taskName, item, state, error );
} );
QObject::connect( m_runner,
&RiuWorkflowJobRunner::runFinished,
m_runner,
@@ -24,6 +24,9 @@
#include "cafPdmField.h"
#include "cafPdmObject.h"
#include <QJsonArray>
#include <QJsonObject>
#include <QJsonValue>
#include <QMap>
#include <QPointer>
@@ -40,18 +43,27 @@ public:
void setJobName( const QString& name );
std::vector<RimWorkflowTaskInput*> taskInputs() const;
RimWorkflowTaskInput* taskInput( const QString& taskName ) const;
void setTaskInputs( std::vector<RimWorkflowTaskInput*> inputs );
void syncTaskInputs( const QJsonArray& graphTasks, const QMap<QString, QString>& renames = {} );
QString writeInputYaml( const QString& path ) const;
void runJob();
void cancelJob();
bool isRunning() const;
QJsonObject inputValues() const;
QJsonObject literalInputValues() const;
QString writeInputYaml( const QString& path ) const;
void runJob();
void cancelJob();
bool isRunning() const;
QMap<QString, QString> taskStates() const;
QMap<QString, QString> taskErrors() const;
QString runStatus() const;
void updateTaskState( const QString& runId, const QString& taskName, const QString& state, const QString& error );
void finishRun( const QString& runId, bool succeeded, bool cancelled );
// States and errors of the items of mapped tasks, by task and item key
QMap<QString, QMap<QString, QString>> itemStates() const;
QMap<QString, QMap<QString, QString>> itemErrors() const;
void updateItemState( const QString& runId, const QString& taskName, const QString& item, const QString& state, const QString& error );
void finishRun( const QString& runId, bool succeeded, bool cancelled );
protected:
void fieldChangedByUi( const caf::PdmFieldHandle* changedField, const QVariant& oldValue, const QVariant& newValue ) override;
@@ -64,8 +76,11 @@ private:
caf::PdmField<QString> m_name;
caf::PdmChildArrayField<RimWorkflowTaskInput*> m_taskInputs;
QPointer<RiuWorkflowJobRunner> m_runner;
QMap<QString, QJsonValue> m_detachedValues;
QMap<QString, QString> m_taskStates;
QMap<QString, QString> m_taskErrors;
QMap<QString, QMap<QString, QString>> m_itemStates;
QMap<QString, QMap<QString, QString>> m_itemErrors;
QString m_runStatus;
QString m_activeTask;
QString m_runId;
@@ -0,0 +1,240 @@
/////////////////////////////////////////////////////////////////////////////////
//
// 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 "RimWorkflowMappingBinding.h"
#include "RimEclipseCase.h"
#include "RimEclipseCaseTools.h"
#include "RimEclipseView.h"
#include "RimProject.h"
#include "RimTools.h"
#include "RimWellPath.h"
#include "cafPdmUiTreeSelectionEditor.h"
#include <QJsonDocument>
#include <QJsonObject>
#include <QSet>
CAF_PDM_SOURCE_INIT( RimWorkflowMappingBinding, "WorkflowMappingBinding" );
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
RimWorkflowMappingBinding::RimWorkflowMappingBinding()
{
CAF_PDM_InitFieldNoDefault( &m_valueType, "ValueType", "Value Type" );
m_valueType.uiCapability()->setUiHidden( true );
CAF_PDM_InitFieldNoDefault( &m_text, "Text", "Items" );
m_text.uiCapability()->setUiToolTip( "A JSON object, for example {\"low\": 0.5, \"high\": 2.0}" );
CAF_PDM_InitFieldNoDefault( &m_cases, "Cases", "Cases" );
CAF_PDM_InitFieldNoDefault( &m_wellPaths, "WellPaths", "Well Paths" );
CAF_PDM_InitFieldNoDefault( &m_views, "Views", "Views" );
for ( caf::PdmFieldHandle* field : std::initializer_list<caf::PdmFieldHandle*>{ &m_cases, &m_wellPaths, &m_views } )
{
field->uiCapability()->setUiEditorTypeName( caf::PdmUiTreeSelectionEditor::uiEditorTypeName() );
field->uiCapability()->setUiLabelPosition( caf::PdmUiItemInfo::LabelPosition::TOP );
}
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
caf::PdmFieldHandle* RimWorkflowMappingBinding::valueField()
{
if ( m_valueType() == "EclipseCase" ) return &m_cases;
if ( m_valueType() == "WellPath" ) return &m_wellPaths;
if ( m_valueType() == "View" ) return &m_views;
return &m_text;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
bool RimWorkflowMappingBinding::isObjectReference() const
{
return !m_valueType().isEmpty();
}
//--------------------------------------------------------------------------------------------------
/// The selected objects as `{key: reference}`, keyed by their names made unique
//--------------------------------------------------------------------------------------------------
std::vector<std::pair<QString, QJsonObject>> RimWorkflowMappingBinding::references() const
{
std::vector<std::pair<QString, QJsonObject>> items;
if ( m_valueType() == "EclipseCase" )
{
for ( RimEclipseCase* eclipseCase : m_cases.ptrReferencedObjectsByType() )
{
if ( eclipseCase )
items.push_back( { eclipseCase->caseUserDescription(),
QJsonObject{ { "__resinsight_ref__", "EclipseCase" }, { "case_id", eclipseCase->caseId() } } } );
}
}
else if ( m_valueType() == "WellPath" )
{
for ( RimWellPath* wellPath : m_wellPaths.ptrReferencedObjectsByType() )
{
if ( wellPath )
items.push_back(
{ wellPath->name(), QJsonObject{ { "__resinsight_ref__", "WellPath" }, { "well_path_name", wellPath->name() } } } );
}
}
else if ( m_valueType() == "View" )
{
for ( RimEclipseView* view : m_views.ptrReferencedObjectsByType() )
{
if ( view ) items.push_back( { view->name(), QJsonObject{ { "__resinsight_ref__", "View" }, { "view_id", view->id() } } } );
}
}
QSet<QString> used;
for ( auto& [key, reference] : items )
{
const QString base = key.isEmpty() ? QString( "item" ) : key;
key = base;
for ( int index = 2; used.contains( key ); ++index )
key = QString( "%1_%2" ).arg( base ).arg( index );
used.insert( key );
}
return items;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QJsonValue RimWorkflowMappingBinding::toJsonValue() const
{
if ( isObjectReference() )
{
QJsonObject mapping;
for ( const auto& [key, reference] : references() )
mapping[key] = reference;
return mapping.isEmpty() ? QJsonValue( QJsonValue::Null ) : QJsonValue( mapping );
}
if ( !hasValue() ) return QJsonValue::Null;
const QJsonDocument document = QJsonDocument::fromJson( m_text().toUtf8() );
return document.isObject() ? QJsonValue( document.object() ) : QJsonValue( m_text() );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QString RimWorkflowMappingBinding::displayValue() const
{
QStringList keys;
if ( isObjectReference() )
{
for ( const auto& [key, reference] : references() )
keys.append( key );
}
else
{
const QJsonValue value = toJsonValue();
if ( !value.isObject() ) return hasValue() ? "(not a JSON object)" : "(not set)";
keys = value.toObject().keys();
}
if ( keys.isEmpty() ) return "(no items)";
const QString count = keys.size() == 1 ? QString( "1 item" ) : QString( "%1 items" ).arg( keys.size() );
QString names = keys.mid( 0, 3 ).join( ", " );
if ( keys.size() > 3 ) names += ", …";
return QString( "%1: %2" ).arg( count, names );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QList<caf::PdmOptionItemInfo> RimWorkflowMappingBinding::calculateValueOptions( const caf::PdmFieldHandle* fieldNeedingOptions )
{
QList<caf::PdmOptionItemInfo> options;
if ( fieldNeedingOptions == &m_cases ) RimTools::eclipseCaseOptionItems( &options );
if ( fieldNeedingOptions == &m_wellPaths ) RimTools::wellPathOptionItems( &options );
if ( fieldNeedingOptions == &m_views )
{
for ( RimEclipseCase* eclipseCase : RimEclipseCaseTools::eclipseCases() )
{
if ( !eclipseCase ) continue;
for ( RimEclipseView* view : eclipseCase->reservoirViews() )
{
if ( view )
options.push_back(
caf::PdmOptionItemInfo( QString( "%1 / %2" ).arg( eclipseCase->caseUserDescription(), view->name() ), view ) );
}
}
}
return options;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RimWorkflowMappingBinding::applySchema( const QJsonObject& fieldSchema )
{
m_valueType = fieldSchema.value( "value_resinsight_type" ).toString();
RimWorkflowFieldBinding::applySchema( fieldSchema );
const QJsonObject mapping = fieldSchema.value( "default" ).toObject();
if ( !isObjectReference() )
{
if ( fieldSchema.value( "default" ).isObject() )
m_text = QString::fromUtf8( QJsonDocument( mapping ).toJson( QJsonDocument::Compact ) );
return;
}
// Restore the objects of a mapping of references
QSet<int> caseIds;
QSet<int> viewIds;
QSet<QString> wellPathNames;
for ( const QJsonValue& value : mapping )
{
const QJsonObject reference = value.toObject();
if ( reference.value( "__resinsight_ref__" ).toString() == "EclipseCase" )
caseIds.insert( reference.value( "case_id" ).toInt( -1 ) );
if ( reference.value( "__resinsight_ref__" ).toString() == "View" ) viewIds.insert( reference.value( "view_id" ).toInt( -1 ) );
if ( reference.value( "__resinsight_ref__" ).toString() == "WellPath" )
wellPathNames.insert( reference.value( "well_path_name" ).toString() );
}
if ( caseIds.isEmpty() && viewIds.isEmpty() && wellPathNames.isEmpty() ) return;
std::vector<RimEclipseCase*> cases;
std::vector<RimEclipseView*> views;
for ( RimEclipseCase* eclipseCase : RimEclipseCaseTools::eclipseCases() )
{
if ( !eclipseCase ) continue;
if ( caseIds.contains( eclipseCase->caseId() ) ) cases.push_back( eclipseCase );
for ( RimEclipseView* view : eclipseCase->reservoirViews() )
{
if ( view && viewIds.contains( view->id() ) ) views.push_back( view );
}
}
std::vector<RimWellPath*> wellPaths;
if ( auto* project = RimProject::current() )
{
for ( RimWellPath* wellPath : project->allWellPaths() )
{
if ( wellPath && wellPathNames.contains( wellPath->name() ) ) wellPaths.push_back( wellPath );
}
}
m_cases.setValue( cases );
m_views.setValue( views );
m_wellPaths.setValue( wellPaths );
}
@@ -0,0 +1,57 @@
/////////////////////////////////////////////////////////////////////////////////
//
// 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.
//
/////////////////////////////////////////////////////////////////////////////////
#pragma once
#include "RimWorkflowFieldBinding.h"
#include "cafPdmPtrArrayField.h"
class RimEclipseCase;
class RimEclipseView;
class RimWellPath;
//==================================================================================================
/// The `dict[str, Value]` a mapped task runs over. ResInsight objects are picked from the project
/// and keyed by name; other values are entered as a JSON object.
//==================================================================================================
class RimWorkflowMappingBinding : public RimWorkflowFieldBinding
{
CAF_PDM_HEADER_INIT;
public:
RimWorkflowMappingBinding();
void applySchema( const QJsonObject& fieldSchema ) override;
QJsonValue toJsonValue() const override;
bool isObjectReference() const override;
QString displayValue() const override;
caf::PdmFieldHandle* valueField() override;
private:
QList<caf::PdmOptionItemInfo> calculateValueOptions( const caf::PdmFieldHandle* fieldNeedingOptions ) override;
std::vector<std::pair<QString, QJsonObject>> references() const;
caf::PdmField<QString> m_valueType;
caf::PdmField<QString> m_text;
caf::PdmPtrArrayField<RimEclipseCase*> m_cases;
caf::PdmPtrArrayField<RimWellPath*> m_wellPaths;
caf::PdmPtrArrayField<RimEclipseView*> m_views;
};
@@ -0,0 +1,716 @@
/////////////////////////////////////////////////////////////////////////////////
//
// 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 "RimWorkflowPortCompatibility.h"
#include "RimWorkflowDefinition.h"
#include "RimWorkflowSchemaTools.h"
#include <QJsonArray>
#include <QMap>
#include <QRegularExpression>
#include <QSet>
#include <QStringList>
namespace
{
QString shortName( const QString& pythonType )
{
return pythonType.mid( pythonType.lastIndexOf( '.' ) + 1 );
}
QStringList pythonTypes( const QJsonObject& schema )
{
QStringList types;
for ( const char* key : { "x-taskmaestro-python-type", "x-ri-python-type" } )
{
const QString type = schema.value( key ).toString();
if ( !type.isEmpty() ) types.append( type );
}
for ( const QJsonValue& base : schema.value( "x-ri-python-bases" ).toArray() )
types.append( base.toString() );
return types;
}
QJsonArray enumValues( const QJsonObject& schema )
{
if ( schema.contains( "const" ) ) return QJsonArray{ schema.value( "const" ) };
return schema.value( "enum" ).toArray();
}
bool isHiddenObjectValue( const QString& name, const QJsonObject& property, const QJsonObject& rootSchema )
{
// ObjectModel wraps its runtime object in an opaque `value` field that is not routable on its own
return name == "value" && RimWorkflowSchemaTools::isOpaque( property, rootSchema );
}
bool isModelCompatible( const QJsonObject& produced, const QJsonObject& expected )
{
const QString expectedType = expected.value( "x-ri-python-type" ).toString();
if ( !expectedType.isEmpty() && produced.contains( "x-ri-python-type" ) ) return pythonTypes( produced ).contains( expectedType );
if ( expected.contains( "title" ) && produced.contains( "title" ) && expected.contains( "properties" ) )
return expected.value( "title" ) == produced.value( "title" );
return true;
}
bool isScalarCompatible( const QJsonObject& produced, const QJsonObject& expected )
{
const QString producedType = produced.value( "type" ).toString();
const QString expectedType = expected.value( "type" ).toString();
// bool is a subclass of int in Python, but int is not a subclass of float
if ( producedType != expectedType && !( producedType == "boolean" && expectedType == "integer" ) ) return false;
if ( produced.value( "format" ).toString() != expected.value( "format" ).toString() ) return false;
const QJsonArray expectedEnum = enumValues( expected );
if ( expectedEnum.isEmpty() ) return true;
const QJsonArray producedEnum = enumValues( produced );
if ( producedEnum.isEmpty() ) return false;
for ( const QJsonValue& value : producedEnum )
{
if ( !expectedEnum.contains( value ) ) return false;
}
return true;
}
std::optional<RimWorkflowPort> findPort( const std::vector<RimWorkflowPort>& ports, const QString& name )
{
for ( const auto& port : ports )
{
if ( port.name == name ) return port;
}
return std::nullopt;
}
bool hasRequiredConfigurableField( const QJsonObject& inputSchema, const QStringList& covered )
{
const QJsonObject properties = inputSchema.value( "properties" ).toObject();
for ( const QString& field : RimWorkflowSchemaTools::requiredFields( inputSchema ) )
{
if ( covered.contains( field ) ) continue;
if ( RimWorkflowSchemaTools::isConfigurable( properties.value( field ).toObject(), inputSchema ) ) return true;
}
return false;
}
std::expected<void, QString>
checkWholeToWhole( const QJsonObject& upstreamType, const QJsonObject& downstreamType, const QString& from, const QString& to )
{
const QJsonObject outputSchema = upstreamType.value( "output_schema" ).toObject();
const QJsonObject inputSchema = downstreamType.value( "input_schema" ).toObject();
if ( RimWorkflowPortCompatibility::isCompatible( outputSchema, outputSchema, inputSchema, inputSchema ) ) return {};
const QString mismatch = QString( "'%1' outputs %2, but '%3' expects %4" )
.arg( from, RimWorkflowPortCompatibility::typeName( outputSchema, outputSchema ) )
.arg( to, RimWorkflowPortCompatibility::typeName( inputSchema, inputSchema ) );
// taskmaestro merges the upstream fields with configured values when the task has config fields
const QStringList shared = RimWorkflowPortCompatibility::coveredByWholeOutput( upstreamType, downstreamType );
if ( shared.isEmpty() || !hasRequiredConfigurableField( inputSchema, shared ) ) return std::unexpected( mismatch );
const QJsonObject outputProperties = outputSchema.value( "properties" ).toObject();
const QJsonObject inputProperties = inputSchema.value( "properties" ).toObject();
for ( const QString& field : shared )
{
if ( !RimWorkflowPortCompatibility::isCompatible( outputProperties.value( field ).toObject(),
outputSchema,
inputProperties.value( field ).toObject(),
inputSchema ) )
{
return std::unexpected( QString( "Type mismatch for field '%1': %2" ).arg( field, mismatch ) );
}
}
return {};
}
//--------------------------------------------------------------------------------------------------
/// Icon of the ResInsight object a schema holds, or an empty string. The rips class is found in the
/// Python class bases: directly for opaque rips objects, or as `ObjectModel[Class]` for wrappers.
/// The icons are the ones the matching ResInsight project classes use.
//--------------------------------------------------------------------------------------------------
QString objectIconResource( const QJsonObject& schema, const QJsonObject& rootSchema )
{
const QJsonObject resolved = RimWorkflowSchemaTools::resolveReferences( schema, rootSchema );
for ( const char* unionKey : { "anyOf", "oneOf" } )
{
for ( const QJsonValue& option : resolved.value( unionKey ).toArray() )
{
const QString icon = objectIconResource( option.toObject(), rootSchema );
if ( !icon.isEmpty() ) return icon;
}
}
if ( resolved.value( "type" ).toString() == "array" ) return objectIconResource( resolved.value( "items" ).toObject(), rootSchema );
if ( resolved.value( "type" ).toString() == "object" && resolved.value( "additionalProperties" ).isObject() &&
!resolved.contains( "properties" ) )
return objectIconResource( resolved.value( "additionalProperties" ).toObject(), rootSchema );
static const QMap<QString, QString> icons = { { "Instance", ":/AppLogo48x48.png" },
{ "Case", ":/Case48x48.png" },
{ "Reservoir", ":/Case48x48.png" },
{ "EclipseCase", ":/Case48x48.png" },
{ "View", ":/3DView16x16.png" },
{ "EclipseView", ":/3DView16x16.png" },
{ "WellPath", ":/Well.svg" },
{ "Surface", ":/ReservoirSurface16x16.png" } };
QJsonArray bases = resolved.value( "x-ri-python-bases" ).toArray();
if ( bases.isEmpty() && resolved.contains( "x-ri-python-type" ) ) bases.append( resolved.value( "x-ri-python-type" ) );
static const QRegularExpression objectModel( R"(ObjectModel\[(\w+)\]$)" );
for ( const QJsonValue& value : bases )
{
const QString base = value.toString();
QString className;
if ( const auto match = objectModel.match( base ); match.hasMatch() )
className = match.captured( 1 );
else if ( base.startsWith( "rips." ) )
className = base.section( '.', -1 );
if ( icons.contains( className ) ) return icons.value( className );
}
return {};
}
//--------------------------------------------------------------------------------------------------
/// Icon of a plain value (string, number, bool, date, path), or an empty string. The icons are Codicons,
/// see Resources/codicons/README.md.
//--------------------------------------------------------------------------------------------------
QString valueIconResource( const QJsonObject& schema, const QJsonObject& rootSchema )
{
const QJsonObject resolved = RimWorkflowSchemaTools::resolveReferences( schema, rootSchema );
for ( const char* unionKey : { "anyOf", "oneOf" } )
{
for ( const QJsonValue& option : resolved.value( unionKey ).toArray() )
{
const QString icon = valueIconResource( option.toObject(), rootSchema );
if ( !icon.isEmpty() ) return icon;
}
}
const QString type = resolved.value( "type" ).toString();
if ( type == "array" ) return valueIconResource( resolved.value( "items" ).toObject(), rootSchema );
if ( type == "object" && resolved.value( "additionalProperties" ).isObject() && !resolved.contains( "properties" ) )
return valueIconResource( resolved.value( "additionalProperties" ).toObject(), rootSchema );
if ( type == "boolean" ) return ":/codicons/check.svg";
if ( type == "integer" || type == "number" ) return ":/codicons/symbol-numeric.svg";
if ( type != "string" ) return {};
const QString format = resolved.value( "format" ).toString();
if ( format == "date" || format == "date-time" ) return ":/codicons/calendar.svg";
if ( format == "directory-path" ) return ":/codicons/folder.svg";
if ( format == "path" || format == "file-path" ) return ":/codicons/file.svg";
return ":/codicons/symbol-string.svg";
}
} // namespace
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
bool RimWorkflowPort::isWhole() const
{
return name.isEmpty();
}
//--------------------------------------------------------------------------------------------------
/// The whole input model first, then its fields
//--------------------------------------------------------------------------------------------------
std::vector<RimWorkflowPort> RimWorkflowPortCompatibility::inputPorts( const QJsonObject& taskType )
{
const QJsonObject inputSchema = taskType.value( "input_schema" ).toObject();
std::vector<RimWorkflowPort> ports;
ports.push_back( { .name = "",
.schema = inputSchema,
.rootSchema = inputSchema,
.description = inputSchema.value( "description" ).toString(),
.typeName = typeName( inputSchema, inputSchema ),
.iconResource = iconResource( inputSchema, inputSchema ),
.required = true } );
const QStringList required = RimWorkflowSchemaTools::requiredFields( inputSchema );
const QJsonObject properties = inputSchema.value( "properties" ).toObject();
for ( const QString& name : RimWorkflowSchemaTools::propertyNames( inputSchema ) )
{
const QJsonObject property = properties.value( name ).toObject();
if ( isHiddenObjectValue( name, property, inputSchema ) ) continue;
ports.push_back( { .name = name,
.schema = property,
.rootSchema = inputSchema,
.description = property.value( "description" ).toString(),
.typeName = typeName( property, inputSchema ),
.iconResource = iconResource( property, inputSchema ),
.required = required.contains( name ),
.configurable = RimWorkflowSchemaTools::isConfigurable( property, inputSchema ) } );
}
return ports;
}
//--------------------------------------------------------------------------------------------------
/// The whole output model first, then its fields
//--------------------------------------------------------------------------------------------------
std::vector<RimWorkflowPort> RimWorkflowPortCompatibility::outputPorts( const QJsonObject& taskType )
{
const QJsonObject outputSchema = taskType.value( "output_schema" ).toObject();
std::vector<RimWorkflowPort> ports;
ports.push_back( { .name = "",
.schema = outputSchema,
.rootSchema = outputSchema,
.description = outputSchema.value( "description" ).toString(),
.typeName = typeName( outputSchema, outputSchema ),
.iconResource = iconResource( outputSchema, outputSchema ) } );
const QJsonObject properties = outputSchema.value( "properties" ).toObject();
for ( const QString& name : RimWorkflowSchemaTools::propertyNames( outputSchema ) )
{
const QJsonObject property = properties.value( name ).toObject();
if ( isHiddenObjectValue( name, property, outputSchema ) ) continue;
ports.push_back( { .name = name,
.schema = property,
.rootSchema = outputSchema,
.description = property.value( "description" ).toString(),
.typeName = typeName( property, outputSchema ),
.iconResource = iconResource( property, outputSchema ) } );
}
return ports;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
std::optional<RimWorkflowPort> RimWorkflowPortCompatibility::inputPort( const QJsonObject& taskType, const QString& name )
{
return findPort( inputPorts( taskType ), name );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
std::optional<RimWorkflowPort> RimWorkflowPortCompatibility::outputPort( const QJsonObject& taskType, const QString& name )
{
return findPort( outputPorts( taskType ), name );
}
//--------------------------------------------------------------------------------------------------
/// A schema without any type information accepts and produces anything
//--------------------------------------------------------------------------------------------------
bool RimWorkflowPortCompatibility::isAny( const QJsonObject& schema, const QJsonObject& rootSchema )
{
const QJsonObject resolved = RimWorkflowSchemaTools::resolveReferences( schema, rootSchema );
for ( const char* key : { "type", "anyOf", "oneOf", "properties", "enum", "const", "x-taskmaestro-opaque", "$ref" } )
{
if ( resolved.contains( key ) ) return false;
}
return true;
}
//--------------------------------------------------------------------------------------------------
/// Whether every value of the produced schema is accepted by the expected schema. Models and
/// runtime objects compare their Python class and its bases, like `issubclass`.
//--------------------------------------------------------------------------------------------------
bool RimWorkflowPortCompatibility::isCompatible( const QJsonObject& produced,
const QJsonObject& producedRoot,
const QJsonObject& expected,
const QJsonObject& expectedRoot )
{
if ( isAny( produced, producedRoot ) || isAny( expected, expectedRoot ) ) return true;
const QJsonObject p = RimWorkflowSchemaTools::resolveReferences( produced, producedRoot );
const QJsonObject e = RimWorkflowSchemaTools::resolveReferences( expected, expectedRoot );
// Every produced option must be accepted; an expected union needs one accepting option
for ( const char* unionKey : { "anyOf", "oneOf" } )
{
if ( !p.value( unionKey ).isArray() ) continue;
for ( const QJsonValue& option : p.value( unionKey ).toArray() )
{
if ( !isCompatible( option.toObject(), producedRoot, e, expectedRoot ) ) return false;
}
return true;
}
for ( const char* unionKey : { "anyOf", "oneOf" } )
{
if ( !e.value( unionKey ).isArray() ) continue;
for ( const QJsonValue& option : e.value( unionKey ).toArray() )
{
if ( isCompatible( p, producedRoot, option.toObject(), expectedRoot ) ) return true;
}
return false;
}
const bool producedOpaque = p.value( "x-taskmaestro-opaque" ).toBool( false );
if ( e.value( "x-taskmaestro-opaque" ).toBool( false ) )
{
return producedOpaque && pythonTypes( p ).contains( e.value( "x-taskmaestro-python-type" ).toString() );
}
if ( producedOpaque ) return false;
const QString producedType = p.value( "type" ).toString();
const QString expectedType = e.value( "type" ).toString();
if ( producedType == "object" || expectedType == "object" )
{
if ( producedType != expectedType ) return false;
if ( e.value( "additionalProperties" ).isObject() && !e.contains( "properties" ) )
{
return isCompatible( p.value( "additionalProperties" ).toObject(),
producedRoot,
e.value( "additionalProperties" ).toObject(),
expectedRoot );
}
return isModelCompatible( p, e );
}
if ( producedType == "array" || expectedType == "array" )
{
if ( producedType != expectedType ) return false;
return isCompatible( p.value( "items" ).toObject(), producedRoot, e.value( "items" ).toObject(), expectedRoot );
}
return isScalarCompatible( p, e );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
bool RimWorkflowPortCompatibility::isCompatible( const RimWorkflowPort& produced, const RimWorkflowPort& expected )
{
return isCompatible( produced.schema, produced.rootSchema, expected.schema, expected.rootSchema );
}
//--------------------------------------------------------------------------------------------------
/// Short readable type name used in tooltips and error messages
//--------------------------------------------------------------------------------------------------
QString RimWorkflowPortCompatibility::typeName( const QJsonObject& schema, const QJsonObject& rootSchema )
{
if ( isAny( schema, rootSchema ) ) return "Any";
const QJsonObject resolved = RimWorkflowSchemaTools::resolveReferences( schema, rootSchema );
for ( const char* unionKey : { "anyOf", "oneOf" } )
{
if ( !resolved.value( unionKey ).isArray() ) continue;
QStringList names;
for ( const QJsonValue& option : resolved.value( unionKey ).toArray() )
names.append( typeName( option.toObject(), rootSchema ) );
return names.join( " | " );
}
if ( resolved.value( "x-taskmaestro-opaque" ).toBool( false ) )
return shortName( resolved.value( "x-taskmaestro-python-type" ).toString( "object" ) );
if ( resolved.contains( "x-ri-python-type" ) ) return shortName( resolved.value( "x-ri-python-type" ).toString() );
const QString type = resolved.value( "type" ).toString();
if ( type == "object" && resolved.value( "additionalProperties" ).isObject() && !resolved.contains( "properties" ) )
return QString( "dict[str, %1]" ).arg( typeName( resolved.value( "additionalProperties" ).toObject(), rootSchema ) );
if ( type == "object" ) return resolved.value( "title" ).toString( "object" );
if ( type == "array" ) return QString( "list[%1]" ).arg( typeName( resolved.value( "items" ).toObject(), rootSchema ) );
static const QMap<QString, QString> pythonNames = { { "string", "str" },
{ "integer", "int" },
{ "number", "float" },
{ "boolean", "bool" },
{ "null", "None" } };
const QString format = resolved.value( "format" ).toString();
if ( format == "date" ) return "date";
if ( format == "date-time" ) return "datetime";
if ( format == "path" || format == "file-path" || format == "directory-path" ) return "Path";
return pythonNames.value( type, type );
}
//--------------------------------------------------------------------------------------------------
/// Icon of a port: the ResInsight object it holds, otherwise the kind of value
//--------------------------------------------------------------------------------------------------
QString RimWorkflowPortCompatibility::iconResource( const QJsonObject& schema, const QJsonObject& rootSchema )
{
const QString objectIcon = objectIconResource( schema, rootSchema );
return objectIcon.isEmpty() ? valueIconResource( schema, rootSchema ) : objectIcon;
}
//--------------------------------------------------------------------------------------------------
/// Port name -> icon resource, for the ports that have an icon
//--------------------------------------------------------------------------------------------------
QJsonObject RimWorkflowPortCompatibility::portIcons( const std::vector<RimWorkflowPort>& ports )
{
QJsonObject icons;
for ( const auto& port : ports )
{
if ( !port.iconResource.isEmpty() ) icons[port.name] = port.iconResource;
}
return icons;
}
//--------------------------------------------------------------------------------------------------
/// Port name -> type name, with an empty name for the whole model. Used to label ports in the graph.
//--------------------------------------------------------------------------------------------------
QJsonObject RimWorkflowPortCompatibility::portTypes( const std::vector<RimWorkflowPort>& ports )
{
QJsonObject types;
for ( const auto& port : ports )
types[port.name] = port.typeName;
return types;
}
//--------------------------------------------------------------------------------------------------
/// Input fields of the downstream task supplied when it takes the whole upstream output
//--------------------------------------------------------------------------------------------------
QStringList RimWorkflowPortCompatibility::coveredByWholeOutput( const QJsonObject& upstreamTaskType, const QJsonObject& downstreamTaskType )
{
const QJsonObject outputSchema = upstreamTaskType.value( "output_schema" ).toObject();
const QJsonObject inputSchema = downstreamTaskType.value( "input_schema" ).toObject();
const QStringList inputFields = RimWorkflowSchemaTools::propertyNames( inputSchema );
if ( isCompatible( outputSchema, outputSchema, inputSchema, inputSchema ) ) return inputFields;
const QStringList outputFields = RimWorkflowSchemaTools::propertyNames( outputSchema );
QStringList covered;
for ( const QString& field : inputFields )
{
if ( outputFields.contains( field ) ) covered.append( field );
}
return covered;
}
//--------------------------------------------------------------------------------------------------
/// Whether adding the connection from -> to closes a cycle
//--------------------------------------------------------------------------------------------------
bool RimWorkflowPortCompatibility::wouldCreateCycle( const RimWorkflowDefinition& definition, const QString& from, const QString& to )
{
if ( from == to ) return true;
QStringList pending{ to };
QSet<QString> visited;
while ( !pending.isEmpty() )
{
const QString current = pending.takeLast();
if ( current == from ) return true;
if ( visited.contains( current ) ) continue;
visited.insert( current );
for ( const auto& edge : definition.edges )
{
if ( edge.from == current ) pending.append( edge.to );
}
}
return false;
}
//--------------------------------------------------------------------------------------------------
/// A mapped task runs once per item: the key and value fields are filled by the map and are not
/// inputs, and the output is `dict[str, Output]`
//--------------------------------------------------------------------------------------------------
QJsonObject RimWorkflowPortCompatibility::mappedTaskType( const QJsonObject& taskType, const RimWorkflowTaskMap& map )
{
QJsonObject inputSchema = taskType.value( "input_schema" ).toObject();
QJsonObject properties = inputSchema.value( "properties" ).toObject();
properties.remove( map.keyAs );
properties.remove( map.valueAs );
inputSchema["properties"] = properties;
QJsonArray required;
for ( const QJsonValue& field : inputSchema.value( "required" ).toArray() )
{
if ( field.toString() != map.keyAs && field.toString() != map.valueAs ) required.append( field );
}
inputSchema["required"] = required;
QJsonObject result = taskType;
result["input_schema"] = inputSchema;
result["output_schema"] = mappedOutputSchema( taskType.value( "output_schema" ).toObject() );
return result;
}
//--------------------------------------------------------------------------------------------------
/// `MappedOutput[Output]`, a `dict[str, Output]`. The output model moves into `$defs`, so its own
/// references still resolve against the new root.
//--------------------------------------------------------------------------------------------------
QJsonObject RimWorkflowPortCompatibility::mappedOutputSchema( const QJsonObject& outputSchema )
{
QJsonObject definitions = outputSchema.value( "$defs" ).toObject();
QJsonObject item = outputSchema;
item.remove( "$defs" );
QString itemKey = "MappedItem";
while ( definitions.contains( itemKey ) )
itemKey += "_";
definitions[itemKey] = item;
return QJsonObject{ { "type", "object" },
{ "title", QString( "dict[str, %1]" ).arg( typeName( outputSchema, outputSchema ) ) },
{ "additionalProperties", QJsonObject{ { "$ref", "#/$defs/" + itemKey } } },
{ "$defs", definitions },
{ "x-ri-mapped-output", true } };
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
std::optional<std::pair<RimWorkflowDefinitionEdge::Collect, QJsonObject>>
RimWorkflowPortCompatibility::collectTarget( const RimWorkflowPort& expected )
{
if ( expected.isWhole() ) return std::nullopt;
const QJsonObject resolved = RimWorkflowSchemaTools::resolveReferences( expected.schema, expected.rootSchema );
QJsonArray options;
for ( const char* unionKey : { "anyOf", "oneOf" } )
{
for ( const QJsonValue& option : resolved.value( unionKey ).toArray() )
options.append( option );
}
if ( options.isEmpty() ) options.append( resolved );
for ( const QJsonValue& value : options )
{
const QJsonObject option = RimWorkflowSchemaTools::resolveReferences( value.toObject(), expected.rootSchema );
const QString type = option.value( "type" ).toString();
if ( type == "array" && option.value( "items" ).isObject() )
return std::make_pair( RimWorkflowDefinitionEdge::Collect::List, option.value( "items" ).toObject() );
if ( type == "object" && option.value( "additionalProperties" ).isObject() && !option.contains( "properties" ) )
return std::make_pair( RimWorkflowDefinitionEdge::Collect::Dict, option.value( "additionalProperties" ).toObject() );
}
return std::nullopt;
}
//--------------------------------------------------------------------------------------------------
/// Checks a new connection and returns the edge to add
//--------------------------------------------------------------------------------------------------
std::expected<RimWorkflowDefinitionEdge, QString> RimWorkflowPortCompatibility::resolveConnection( const RimWorkflowDefinition& definition,
const QString& from,
const QString& output,
const QString& to,
const QString& input )
{
using Collect = RimWorkflowDefinitionEdge::Collect;
if ( from == to ) return std::unexpected( "A task cannot be connected to itself" );
if ( !definition.findNode( from ) ) return std::unexpected( QString( "Unknown task '%1'" ).arg( from ) );
const auto* downstream = definition.findNode( to );
if ( !downstream ) return std::unexpected( QString( "Unknown task '%1'" ).arg( to ) );
const QJsonObject upstreamType = definition.taskTypeForNode( from );
const QJsonObject downstreamType = definition.taskTypeForNode( to );
if ( upstreamType.isEmpty() ) return std::unexpected( QString( "The task type of '%1' is not installed" ).arg( from ) );
if ( downstreamType.isEmpty() ) return std::unexpected( QString( "The task type of '%1' is not installed" ).arg( to ) );
const auto producedPort = outputPort( upstreamType, output );
if ( !producedPort ) return std::unexpected( QString( "'%1' has no output '%2'" ).arg( from, output ) );
const auto expectedPort = inputPort( downstreamType, input );
if ( !expectedPort ) return std::unexpected( QString( "'%1' has no input '%2'" ).arg( to, input ) );
if ( downstream->map && input.isEmpty() )
return std::unexpected( QString( "'%1' is mapped and takes its inputs field by field" ).arg( to ) );
RimWorkflowDefinitionEdge edge{ .from = from, .output = output, .to = to, .input = input };
if ( !input.isEmpty() && !isCompatible( *producedPort, *expectedPort ) )
{
const auto target = collectTarget( *expectedPort );
if ( target && isCompatible( producedPort->schema, producedPort->rootSchema, target->second, expectedPort->rootSchema ) )
{
edge.collect = target->first;
}
else
{
const QString source = output.isEmpty() ? from : from + "." + output;
return std::unexpected(
QString( "%1 is %2, but %3.%4 expects %5" ).arg( source, producedPort->typeName, to, input, expectedPort->typeName ) );
}
}
// Edges into the same input are replaced by the new connection, except other collected members
RimWorkflowDefinition remaining = definition;
std::erase_if( remaining.edges,
[&]( const RimWorkflowDefinitionEdge& existing )
{ return existing.to == to && existing.input == input && ( !edge.isCollected() || existing.collect != edge.collect ); } );
if ( wouldCreateCycle( remaining, from, to ) ) return std::unexpected( "The connection would create a cycle" );
QStringList keys;
for ( const auto& existing : remaining.incomingEdges( to ) )
{
if ( input.isEmpty() && !existing.isWholeInput() )
return std::unexpected( QString( "'%1' already has individual inputs connected and cannot also take a whole input" ).arg( to ) );
if ( !input.isEmpty() && existing.isWholeInput() )
return std::unexpected( QString( "'%1' takes its whole input from '%2'; disconnect it first" ).arg( to, existing.from ) );
if ( existing.input != input ) continue;
if ( existing.sameConnection( edge ) )
return std::unexpected( QString( "%1 is already collected into %2.%3" ).arg( from, to, input ) );
keys.append( existing.key );
}
if ( input.isEmpty() && output.isEmpty() )
{
if ( auto check = checkWholeToWhole( upstreamType, downstreamType, from, to ); !check ) return std::unexpected( check.error() );
return edge;
}
if ( input.isEmpty() && !isCompatible( *producedPort, *expectedPort ) )
{
return std::unexpected(
QString( "%1.%2 is %3, but %4 expects %5" ).arg( from, output, producedPort->typeName, to, expectedPort->typeName ) );
}
if ( edge.collect == Collect::Dict )
{
// The upstream task name is the default key
edge.key = output.isEmpty() ? from : from + "_" + output;
for ( int index = 2; keys.contains( edge.key ); ++index )
edge.key = QString( "%1_%2" ).arg( output.isEmpty() ? from : from + "_" + output ).arg( index );
}
return edge;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
std::expected<void, QString> RimWorkflowPortCompatibility::canConnect( const RimWorkflowDefinition& definition,
const QString& from,
const QString& output,
const QString& to,
const QString& input )
{
auto edge = resolveConnection( definition, from, output, to, input );
if ( !edge ) return std::unexpected( edge.error() );
return {};
}
//--------------------------------------------------------------------------------------------------
/// Type check of one member of a collected input
//--------------------------------------------------------------------------------------------------
std::expected<void, QString> RimWorkflowPortCompatibility::checkCollectMember( const RimWorkflowDefinition& definition,
const RimWorkflowDefinitionEdge& edge )
{
const QJsonObject upstreamType = definition.taskTypeForNode( edge.from );
const QJsonObject downstreamType = definition.taskTypeForNode( edge.to );
if ( upstreamType.isEmpty() || downstreamType.isEmpty() ) return {};
const auto producedPort = outputPort( upstreamType, edge.output );
if ( !producedPort ) return std::unexpected( QString( "'%1' has no output '%2'" ).arg( edge.from, edge.output ) );
const auto expectedPort = inputPort( downstreamType, edge.input );
if ( !expectedPort || expectedPort->isWhole() )
return std::unexpected( QString( "'%1' has no input '%2'" ).arg( edge.to, edge.input ) );
const auto target = collectTarget( *expectedPort );
const bool isList = edge.collect == RimWorkflowDefinitionEdge::Collect::List;
if ( !target || target->first != edge.collect )
{
return std::unexpected( QString( "Input '%1' is %2 and cannot collect %3" )
.arg( edge.input, expectedPort->typeName, isList ? "a list of members" : "keyed members" ) );
}
if ( !isCompatible( producedPort->schema, producedPort->rootSchema, target->second, expectedPort->rootSchema ) )
{
const QString source = edge.output.isEmpty() ? edge.from : edge.from + "." + edge.output;
return std::unexpected(
QString( "%1 is %2, but the members of %3.%4 must be %5" )
.arg( source, producedPort->typeName, edge.to, edge.input, typeName( target->second, expectedPort->rootSchema ) ) );
}
return {};
}
@@ -0,0 +1,87 @@
/////////////////////////////////////////////////////////////////////////////////
//
// 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.
//
/////////////////////////////////////////////////////////////////////////////////
#pragma once
#include "RimWorkflowDefinition.h"
#include <QJsonObject>
#include <QString>
#include <expected>
#include <optional>
#include <vector>
//==================================================================================================
/// A connectable input or output of a task. An empty name is the whole input or output model.
//==================================================================================================
struct RimWorkflowPort
{
QString name{};
QJsonObject schema{};
QJsonObject rootSchema{};
QString description{};
QString typeName{};
QString iconResource{};
bool required = true;
bool configurable = false;
bool isWhole() const;
};
//==================================================================================================
/// Static type checks for workflow connections. These mirror taskmaestro's `_is_type_compatible`
/// on the JSON schemas; `taskmaestro workflow describe` has the final say.
//==================================================================================================
namespace RimWorkflowPortCompatibility
{
std::vector<RimWorkflowPort> inputPorts( const QJsonObject& taskType );
std::vector<RimWorkflowPort> outputPorts( const QJsonObject& taskType );
std::optional<RimWorkflowPort> inputPort( const QJsonObject& taskType, const QString& name );
std::optional<RimWorkflowPort> outputPort( const QJsonObject& taskType, const QString& name );
bool isCompatible( const QJsonObject& produced, const QJsonObject& producedRoot, const QJsonObject& expected, const QJsonObject& expectedRoot );
bool isCompatible( const RimWorkflowPort& produced, const RimWorkflowPort& expected );
bool isAny( const QJsonObject& schema, const QJsonObject& rootSchema );
QString typeName( const QJsonObject& schema, const QJsonObject& rootSchema );
QString iconResource( const QJsonObject& schema, const QJsonObject& rootSchema );
QJsonObject portTypes( const std::vector<RimWorkflowPort>& ports );
QJsonObject portIcons( const std::vector<RimWorkflowPort>& ports );
QStringList coveredByWholeOutput( const QJsonObject& upstreamTaskType, const QJsonObject& downstreamTaskType );
bool wouldCreateCycle( const RimWorkflowDefinition& definition, const QString& from, const QString& to );
// Mapped tasks
QJsonObject mappedTaskType( const QJsonObject& taskType, const RimWorkflowTaskMap& map );
QJsonObject mappedOutputSchema( const QJsonObject& outputSchema );
// Collected inputs: the kind and element schema of a `list[T]` or `dict[str, T]` input
std::optional<std::pair<RimWorkflowDefinitionEdge::Collect, QJsonObject>> collectTarget( const RimWorkflowPort& expected );
// The edge to add for a new connection, collecting into a list or dict input when the upstream
// value is an element of it. An existing connection to the same input is replaced, except for
// other members of the same collection.
std::expected<RimWorkflowDefinitionEdge, QString> resolveConnection( const RimWorkflowDefinition& definition,
const QString& from,
const QString& output,
const QString& to,
const QString& input );
std::expected<void, QString>
canConnect( const RimWorkflowDefinition& definition, const QString& from, const QString& output, const QString& to, const QString& input );
std::expected<void, QString> checkCollectMember( const RimWorkflowDefinition& definition, const RimWorkflowDefinitionEdge& edge );
} // namespace RimWorkflowPortCompatibility
@@ -0,0 +1,265 @@
/////////////////////////////////////////////////////////////////////////////////
//
// 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 "RimWorkflowSchemaTools.h"
#include <QJsonArray>
#include <QJsonValue>
#include <QMap>
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QJsonObject RimWorkflowSchemaTools::resolveRef( const QString& ref, const QJsonObject& rootSchema )
{
const QString prefix = "#/$defs/";
if ( !ref.startsWith( prefix ) ) return {};
return rootSchema.value( "$defs" ).toObject().value( ref.mid( prefix.size() ) ).toObject();
}
//--------------------------------------------------------------------------------------------------
/// Follow $ref and unwrap single-item allOf, keeping the sibling keywords of a $ref (description, default)
//--------------------------------------------------------------------------------------------------
QJsonObject RimWorkflowSchemaTools::resolveReferences( QJsonObject schema, const QJsonObject& rootSchema )
{
constexpr int maxDepth = 16;
for ( int depth = 0; depth < maxDepth; ++depth )
{
if ( schema.contains( "$ref" ) )
{
QJsonObject target = resolveRef( schema.value( "$ref" ).toString(), rootSchema );
schema.remove( "$ref" );
for ( auto it = schema.begin(); it != schema.end(); ++it )
{
if ( !target.contains( it.key() ) ) target.insert( it.key(), it.value() );
}
schema = target;
continue;
}
const QJsonArray allOf = schema.value( "allOf" ).toArray();
if ( allOf.size() == 1 )
{
schema = allOf.first().toObject();
continue;
}
break;
}
return schema;
}
//--------------------------------------------------------------------------------------------------
/// Follow $ref and unwrap single-item allOf and Optional (anyOf with null) to the underlying type
//--------------------------------------------------------------------------------------------------
QJsonObject RimWorkflowSchemaTools::underlyingSchema( QJsonObject schema, const QJsonObject& rootSchema )
{
constexpr int maxDepth = 16;
for ( int depth = 0; depth < maxDepth; ++depth )
{
if ( schema.contains( "$ref" ) )
{
schema = resolveRef( schema.value( "$ref" ).toString(), rootSchema );
continue;
}
const QJsonArray allOf = schema.value( "allOf" ).toArray();
if ( allOf.size() == 1 )
{
schema = allOf.first().toObject();
continue;
}
if ( schema.value( "anyOf" ).isArray() )
{
QJsonArray nonNull;
for ( const QJsonValue& arm : schema.value( "anyOf" ).toArray() )
{
if ( arm.toObject().value( "type" ).toString() != "null" ) nonNull.append( arm );
}
if ( nonNull.size() == 1 )
{
schema = nonNull.first().toObject();
continue;
}
}
break;
}
return schema;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
bool RimWorkflowSchemaTools::isOpaque( const QJsonObject& schema, const QJsonObject& rootSchema )
{
return underlyingSchema( schema, rootSchema ).value( "x-taskmaestro-opaque" ).toBool( false );
}
//--------------------------------------------------------------------------------------------------
/// Python type of a runtime-only object, either bare or wrapped in an ObjectModel `value` field
//--------------------------------------------------------------------------------------------------
QString RimWorkflowSchemaTools::objectPythonType( const QJsonObject& typeSchema, const QJsonObject& rootSchema )
{
if ( typeSchema.contains( "x-taskmaestro-python-type" ) ) return typeSchema.value( "x-taskmaestro-python-type" ).toString();
const QJsonObject valueSchema = typeSchema.value( "properties" ).toObject().value( "value" ).toObject();
if ( valueSchema.isEmpty() ) return {};
return underlyingSchema( valueSchema, rootSchema ).value( "x-taskmaestro-python-type" ).toString();
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QString RimWorkflowSchemaTools::jsonType( const QJsonObject& typeSchema )
{
const QString type = typeSchema.value( "type" ).toString();
if ( type == "boolean" || type == "integer" || type == "number" || type == "array" ) return type;
return "string";
}
//--------------------------------------------------------------------------------------------------
/// taskmaestro_resinsight.models.Vec3 is a plain pydantic model (x, y, z numbers), so it has no
/// x-taskmaestro-python-type tag; recognize it structurally instead.
//--------------------------------------------------------------------------------------------------
bool RimWorkflowSchemaTools::isVec3Schema( const QJsonObject& typeSchema )
{
if ( typeSchema.value( "type" ).toString() != "object" ) return false;
const QJsonObject properties = typeSchema.value( "properties" ).toObject();
if ( properties.size() != 3 ) return false;
for ( const QString& component : QStringList{ "x", "y", "z" } )
{
if ( properties.value( component ).toObject().value( "type" ).toString() != "number" ) return false;
}
return true;
}
//--------------------------------------------------------------------------------------------------
/// Map a Python class path such as `rips.generated.generated_classes.WellPath` to the ResInsight
/// object type used to select the field binding. Returns an empty string for unsupported types.
//--------------------------------------------------------------------------------------------------
QString RimWorkflowSchemaTools::resinsightTypeFromPythonType( const QString& pythonType )
{
const int separator = pythonType.lastIndexOf( '.' );
if ( separator < 0 ) return {};
const QString module = pythonType.left( separator );
if ( module != "rips" && !module.startsWith( "rips." ) ) return {};
static const QMap<QString, QString> typeMap = { { "Case", "EclipseCase" },
{ "Reservoir", "EclipseCase" },
{ "EclipseCase", "EclipseCase" },
{ "WellPath", "WellPath" },
{ "View", "View" },
{ "EclipseView", "View" } };
return typeMap.value( pythonType.mid( separator + 1 ) );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QStringList RimWorkflowSchemaTools::requiredFields( const QJsonObject& modelSchema )
{
QStringList fields;
for ( const QJsonValue& value : modelSchema.value( "required" ).toArray() )
fields.append( value.toString() );
return fields;
}
//--------------------------------------------------------------------------------------------------
/// Property names in schema order
//--------------------------------------------------------------------------------------------------
QStringList RimWorkflowSchemaTools::propertyNames( const QJsonObject& modelSchema )
{
// QJsonObject sorts its keys; the `required` list and the original document keep model order
QStringList names;
const QJsonObject properties = modelSchema.value( "properties" ).toObject();
for ( const QString& name : requiredFields( modelSchema ) )
{
if ( properties.contains( name ) && !names.contains( name ) ) names.append( name );
}
for ( const QString& name : properties.keys() )
{
if ( !names.contains( name ) ) names.append( name );
}
return names;
}
//--------------------------------------------------------------------------------------------------
/// Whether a value for the input property can be entered in ResInsight (a literal or an object reference)
//--------------------------------------------------------------------------------------------------
bool RimWorkflowSchemaTools::isConfigurable( const QJsonObject& property, const QJsonObject& rootSchema )
{
const QJsonObject typeSchema = underlyingSchema( property, rootSchema );
const QString pythonType = objectPythonType( typeSchema, rootSchema );
if ( !pythonType.isEmpty() )
{
// An object model with more required fields than `value` cannot be given as a single object
QStringList required = requiredFields( typeSchema );
required.removeAll( "value" );
return required.isEmpty() && !resinsightTypeFromPythonType( pythonType ).isEmpty();
}
if ( typeSchema.value( "x-taskmaestro-opaque" ).toBool( false ) ) return false;
if ( isVec3Schema( typeSchema ) ) return true;
return typeSchema.value( "type" ).toString() != "object";
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QJsonObject RimWorkflowSchemaTools::configFieldSchema( const QString& fieldName,
const QJsonObject& inputSchema,
const QStringList& requiredFields,
const QJsonObject& configValues )
{
QJsonObject entry{ { "name", fieldName } };
const QJsonObject properties = inputSchema.value( "properties" ).toObject();
if ( !properties.contains( fieldName ) )
{
entry["type"] = "string";
entry["required"] = true;
entry["error"] = "field not found in input model";
return entry;
}
const QJsonObject property = properties.value( fieldName ).toObject();
const QJsonObject typeSchema = underlyingSchema( property, inputSchema );
QString resinsightType = resinsightTypeFromPythonType( objectPythonType( typeSchema, inputSchema ) );
if ( resinsightType.isEmpty() && isVec3Schema( typeSchema ) ) resinsightType = "Vec3";
entry["type"] = resinsightType.isEmpty() ? jsonType( typeSchema ) : "object";
entry["required"] = requiredFields.contains( fieldName );
const QString description = property.value( "description" ).toString();
if ( !description.isEmpty() ) entry["description"] = description;
// Prefer the value already in input.yaml; fall back to the model default
if ( configValues.contains( fieldName ) )
entry["default"] = configValues.value( fieldName );
else if ( property.contains( "default" ) )
entry["default"] = property.value( "default" );
const QString format = typeSchema.value( "format" ).toString();
if ( !format.isEmpty() ) entry["format"] = format;
if ( !resinsightType.isEmpty() ) entry["resinsight_type"] = resinsightType;
return entry;
}
@@ -0,0 +1,46 @@
/////////////////////////////////////////////////////////////////////////////////
//
// 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.
//
/////////////////////////////////////////////////////////////////////////////////
#pragma once
#include <QJsonObject>
#include <QString>
#include <QStringList>
//==================================================================================================
/// Helpers for the JSON schemas that taskmaestro generates for task input and output models
//==================================================================================================
namespace RimWorkflowSchemaTools
{
QJsonObject resolveRef( const QString& ref, const QJsonObject& rootSchema );
QJsonObject resolveReferences( QJsonObject schema, const QJsonObject& rootSchema );
QJsonObject underlyingSchema( QJsonObject schema, const QJsonObject& rootSchema );
bool isOpaque( const QJsonObject& schema, const QJsonObject& rootSchema );
QString objectPythonType( const QJsonObject& typeSchema, const QJsonObject& rootSchema );
QString jsonType( const QJsonObject& typeSchema );
bool isVec3Schema( const QJsonObject& typeSchema );
QString resinsightTypeFromPythonType( const QString& pythonType );
QStringList requiredFields( const QJsonObject& modelSchema );
QStringList propertyNames( const QJsonObject& modelSchema );
bool isConfigurable( const QJsonObject& property, const QJsonObject& rootSchema );
QJsonObject configFieldSchema( const QString& fieldName,
const QJsonObject& inputSchema,
const QStringList& requiredFields,
const QJsonObject& configValues );
} // namespace RimWorkflowSchemaTools
@@ -45,7 +45,7 @@ void RimWorkflowStringBinding::applySchema( const QJsonObject& fieldSchema )
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QString RimWorkflowStringBinding::toYamlValue() const
QJsonValue RimWorkflowStringBinding::toJsonValue() const
{
return hasValue() ? yamlQuotedScalar( m_value() ) : "null";
return hasValue() ? QJsonValue( m_value() ) : QJsonValue( QJsonValue::Null );
}
@@ -27,8 +27,8 @@ class RimWorkflowStringBinding : public RimWorkflowFieldBinding
public:
RimWorkflowStringBinding();
void applySchema( const QJsonObject& fieldSchema ) override;
QString toYamlValue() const override;
void applySchema( const QJsonObject& fieldSchema ) override;
QJsonValue toJsonValue() const override;
caf::PdmFieldHandle* valueField() override { return &m_value; }
@@ -0,0 +1,192 @@
/////////////////////////////////////////////////////////////////////////////////
//
// 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 "RimWorkflowTaskCatalog.h"
#include "RimWorkflowHelperProcess.h"
#include "RiaLogging.h"
#include <optional>
namespace
{
constexpr int catalogTimeoutMs = 120000;
std::optional<RimWorkflowTaskCatalog>& cachedCatalog()
{
static std::optional<RimWorkflowTaskCatalog> catalog;
return catalog;
}
RimWorkflowTaskCatalog loadCatalog()
{
auto result = RimWorkflowHelperProcess::runSync( { "catalog" }, {}, catalogTimeoutMs );
if ( !result )
{
RiaLogging::warning( QString( "Could not load the workflow task catalog: %1" ).arg( result.error() ).toStdString() );
return RimWorkflowTaskCatalog::failed( result.error() );
}
auto catalog = RimWorkflowTaskCatalog::fromJson( result.value() );
for ( const QString& error : catalog.pluginErrors() )
RiaLogging::warning( QString( "Workflow plugin: %1" ).arg( error ).toStdString() );
return catalog;
}
QString pluginErrorText( const QJsonValue& error )
{
if ( error.isString() ) return error.toString();
const QJsonObject object = error.toObject();
const QString source = object.value( "id" ).toString( object.value( "entry_point" ).toString() );
const QString text = object.value( "message" ).toString();
return source.isEmpty() ? text : source + ": " + text;
}
} // namespace
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
RimWorkflowTaskCatalog RimWorkflowTaskCatalog::fromJson( const QJsonObject& catalog )
{
RimWorkflowTaskCatalog result;
result.m_valid = true;
result.m_taskmaestroVersion = catalog.value( "taskmaestro_version" ).toString();
result.m_workflows = catalog.value( "workflows" ).toArray();
for ( const QJsonValue& error : catalog.value( "errors" ).toArray() )
result.m_pluginErrors.append( pluginErrorText( error ) );
for ( const QJsonValue& value : catalog.value( "tasks" ).toArray() )
{
const QJsonObject task = value.toObject();
const QString id = task.value( "id" ).toString();
if ( id.isEmpty() || result.m_taskTypes.contains( id ) ) continue;
result.m_taskTypes.insert( id, task );
result.m_taskOrder.append( id );
}
return result;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
RimWorkflowTaskCatalog RimWorkflowTaskCatalog::failed( const QString& errorMessage )
{
RimWorkflowTaskCatalog result;
result.m_loadError = errorMessage;
return result;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
const RimWorkflowTaskCatalog& RimWorkflowTaskCatalog::instance()
{
auto& catalog = cachedCatalog();
if ( !catalog ) catalog = loadCatalog();
return *catalog;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RimWorkflowTaskCatalog::invalidate()
{
cachedCatalog().reset();
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RimWorkflowTaskCatalog::setInstance( const RimWorkflowTaskCatalog& catalog )
{
cachedCatalog() = catalog;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
bool RimWorkflowTaskCatalog::isValid() const
{
return m_valid;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QString RimWorkflowTaskCatalog::loadError() const
{
return m_loadError;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QStringList RimWorkflowTaskCatalog::pluginErrors() const
{
return m_pluginErrors;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QString RimWorkflowTaskCatalog::taskmaestroVersion() const
{
return m_taskmaestroVersion;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QJsonObject RimWorkflowTaskCatalog::taskType( const QString& taskId ) const
{
return m_taskTypes.value( taskId ).toObject();
}
//--------------------------------------------------------------------------------------------------
/// Task descriptors in catalog order
//--------------------------------------------------------------------------------------------------
std::vector<QJsonObject> RimWorkflowTaskCatalog::tasks() const
{
std::vector<QJsonObject> result;
result.reserve( m_taskOrder.size() );
for ( const QString& id : m_taskOrder )
result.push_back( m_taskTypes.value( id ).toObject() );
return result;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QJsonArray RimWorkflowTaskCatalog::registeredWorkflows() const
{
return m_workflows;
}
//--------------------------------------------------------------------------------------------------
/// Add task types (from a workflow definition) that are not in the catalog
//--------------------------------------------------------------------------------------------------
void RimWorkflowTaskCatalog::merge( const QJsonObject& taskTypes )
{
for ( auto it = taskTypes.begin(); it != taskTypes.end(); ++it )
{
if ( m_taskTypes.contains( it.key() ) || !it.value().isObject() ) continue;
m_taskTypes.insert( it.key(), it.value() );
m_taskOrder.append( it.key() );
}
}
@@ -0,0 +1,61 @@
/////////////////////////////////////////////////////////////////////////////////
//
// 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.
//
/////////////////////////////////////////////////////////////////////////////////
#pragma once
#include <QJsonArray>
#include <QJsonObject>
#include <QString>
#include <QStringList>
#include <vector>
//==================================================================================================
/// Task types and registered workflows published by installed packages (`taskmaestro_helper catalog`).
/// The process-wide instance is loaded on first use and cached until `invalidate()` (Rescan).
//==================================================================================================
class RimWorkflowTaskCatalog
{
public:
static RimWorkflowTaskCatalog fromJson( const QJsonObject& catalog );
static RimWorkflowTaskCatalog failed( const QString& errorMessage );
static const RimWorkflowTaskCatalog& instance();
static void invalidate();
static void setInstance( const RimWorkflowTaskCatalog& catalog );
bool isValid() const;
QString loadError() const;
QStringList pluginErrors() const;
QString taskmaestroVersion() const;
QJsonObject taskType( const QString& taskId ) const;
std::vector<QJsonObject> tasks() const;
QJsonArray registeredWorkflows() const;
void merge( const QJsonObject& taskTypes );
private:
bool m_valid = false;
QString m_loadError;
QStringList m_pluginErrors;
QString m_taskmaestroVersion;
QJsonObject m_taskTypes;
QStringList m_taskOrder;
QJsonArray m_workflows;
};
@@ -15,7 +15,6 @@
// for more details.
//
/////////////////////////////////////////////////////////////////////////////////
#include "RimWorkflowTaskInput.h"
#include "RimWorkflowArrayBinding.h"
@@ -25,45 +24,20 @@
#include "RimWorkflowFilePathBinding.h"
#include "RimWorkflowFloatBinding.h"
#include "RimWorkflowIntBinding.h"
#include "RimWorkflowMappingBinding.h"
#include "RimWorkflowStringBinding.h"
#include "RimWorkflowVec3Binding.h"
#include "RimWorkflowViewBinding.h"
#include "RimWorkflowWellPathBinding.h"
#include "cafPdmUiOrdering.h"
#include "cafPdmUiTextEditor.h"
#include "cafPdmUiTreeOrdering.h"
#include <QJsonArray>
#include <QJsonObject>
CAF_PDM_SOURCE_INIT( RimWorkflowTaskInput, "WorkflowTaskInput" );
namespace
{
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
RimWorkflowFieldBinding* createBinding( const QJsonObject& schema )
{
const QString resinsightType = schema.value( "resinsight_type" ).toString();
if ( resinsightType == "EclipseCase" ) return new RimWorkflowCaseBinding;
if ( resinsightType == "WellPath" ) return new RimWorkflowWellPathBinding;
if ( resinsightType == "View" ) return new RimWorkflowViewBinding;
if ( resinsightType == "Vec3" ) return new RimWorkflowVec3Binding;
const QString type = schema.value( "type" ).toString( "string" );
const QString format = schema.value( "format" ).toString();
if ( type == "string" && format == "date" ) return new RimWorkflowDateBinding;
if ( type == "string" && ( format == "path" || format == "file-path" || format == "directory-path" ) )
return new RimWorkflowFilePathBinding;
if ( type == "boolean" ) return new RimWorkflowBoolBinding;
if ( type == "integer" ) return new RimWorkflowIntBinding;
if ( type == "number" ) return new RimWorkflowFloatBinding;
if ( type == "array" ) return new RimWorkflowArrayBinding;
return new RimWorkflowStringBinding;
}
} // namespace
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
@@ -74,6 +48,13 @@ RimWorkflowTaskInput::RimWorkflowTaskInput()
CAF_PDM_InitFieldNoDefault( &m_taskName, "TaskName", "Task" );
m_taskName.uiCapability()->setUiReadOnly( true );
CAF_PDM_InitFieldNoDefault( &m_taskType, "TaskType", "Task Type" );
m_taskType.uiCapability()->setUiReadOnly( true );
CAF_PDM_InitFieldNoDefault( &m_taskDescription, "TaskDescription", "Description" );
m_taskDescription.uiCapability()->setUiReadOnly( true );
m_taskDescription.uiCapability()->setUiEditorTypeName( caf::PdmUiTextEditor::uiEditorTypeName() );
}
//--------------------------------------------------------------------------------------------------
@@ -93,19 +74,168 @@ void RimWorkflowTaskInput::setTaskName( const QString& name )
setUiName( name );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QString RimWorkflowTaskInput::taskType() const
{
return m_taskType();
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RimWorkflowTaskInput::setTaskInfo( const QString& taskType, const QString& description )
{
m_taskType = taskType;
m_taskDescription = description;
}
//--------------------------------------------------------------------------------------------------
/// The class keyword of the binding used for a config field schema
//--------------------------------------------------------------------------------------------------
QString RimWorkflowTaskInput::bindingClassKeyword( const QJsonObject& fieldSchema )
{
if ( fieldSchema.value( "map_over" ).toBool() ) return RimWorkflowMappingBinding::classKeywordStatic();
const QString resinsightType = fieldSchema.value( "resinsight_type" ).toString();
if ( resinsightType == "EclipseCase" ) return RimWorkflowCaseBinding::classKeywordStatic();
if ( resinsightType == "WellPath" ) return RimWorkflowWellPathBinding::classKeywordStatic();
if ( resinsightType == "View" ) return RimWorkflowViewBinding::classKeywordStatic();
if ( resinsightType == "Vec3" ) return RimWorkflowVec3Binding::classKeywordStatic();
const QString type = fieldSchema.value( "type" ).toString( "string" );
const QString format = fieldSchema.value( "format" ).toString();
if ( type == "string" && format == "date" ) return RimWorkflowDateBinding::classKeywordStatic();
if ( type == "string" && ( format == "path" || format == "file-path" || format == "directory-path" ) )
return RimWorkflowFilePathBinding::classKeywordStatic();
if ( type == "boolean" ) return RimWorkflowBoolBinding::classKeywordStatic();
if ( type == "integer" ) return RimWorkflowIntBinding::classKeywordStatic();
if ( type == "number" ) return RimWorkflowFloatBinding::classKeywordStatic();
if ( type == "array" ) return RimWorkflowArrayBinding::classKeywordStatic();
return RimWorkflowStringBinding::classKeywordStatic();
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
RimWorkflowFieldBinding* RimWorkflowTaskInput::createBinding( const QJsonObject& fieldSchema )
{
const QString keyword = bindingClassKeyword( fieldSchema );
if ( keyword == RimWorkflowCaseBinding::classKeywordStatic() ) return new RimWorkflowCaseBinding;
if ( keyword == RimWorkflowWellPathBinding::classKeywordStatic() ) return new RimWorkflowWellPathBinding;
if ( keyword == RimWorkflowViewBinding::classKeywordStatic() ) return new RimWorkflowViewBinding;
if ( keyword == RimWorkflowVec3Binding::classKeywordStatic() ) return new RimWorkflowVec3Binding;
if ( keyword == RimWorkflowDateBinding::classKeywordStatic() ) return new RimWorkflowDateBinding;
if ( keyword == RimWorkflowFilePathBinding::classKeywordStatic() ) return new RimWorkflowFilePathBinding;
if ( keyword == RimWorkflowBoolBinding::classKeywordStatic() ) return new RimWorkflowBoolBinding;
if ( keyword == RimWorkflowIntBinding::classKeywordStatic() ) return new RimWorkflowIntBinding;
if ( keyword == RimWorkflowFloatBinding::classKeywordStatic() ) return new RimWorkflowFloatBinding;
if ( keyword == RimWorkflowArrayBinding::classKeywordStatic() ) return new RimWorkflowArrayBinding;
if ( keyword == RimWorkflowMappingBinding::classKeywordStatic() ) return new RimWorkflowMappingBinding;
return new RimWorkflowStringBinding;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QString RimWorkflowTaskInput::detachedKey( const QString& fieldName, const QString& classKeyword )
{
return fieldName + "/" + classKeyword;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RimWorkflowTaskInput::buildFromSchema( const QJsonArray& configFields )
{
QMap<QString, QJsonValue> noDetachedValues;
deleteAllItems();
for ( const QJsonValue& v : configFields )
syncFromSchema( configFields, noDetachedValues );
}
//--------------------------------------------------------------------------------------------------
/// Update the bindings to a new list of config fields. A binding is kept with its value when the
/// field still has the same kind of binding. Values of removed bindings are moved to
/// `detachedValues`, and restored from there when the field comes back.
//--------------------------------------------------------------------------------------------------
void RimWorkflowTaskInput::syncFromSchema( const QJsonArray& configFields, QMap<QString, QJsonValue>& detachedValues )
{
std::vector<RimWorkflowFieldBinding*> existing = items();
m_items.clearWithoutDelete();
for ( const QJsonValue& value : configFields )
{
const QJsonObject schema = v.toObject();
auto* binding = createBinding( schema );
QJsonObject schema = value.toObject();
const QString field = schema.value( "name" ).toString();
const QString keyword = bindingClassKeyword( schema );
RimWorkflowFieldBinding* binding = nullptr;
for ( auto it = existing.begin(); it != existing.end(); ++it )
{
if ( *it && ( *it )->fieldName() == field && ( *it )->classKeyword() == keyword )
{
binding = *it;
existing.erase( it );
break;
}
}
if ( binding )
{
const QJsonValue current = binding->toJsonValue();
// Keep a cleared value cleared instead of restoring the default
if ( current.isNull() )
schema.remove( "default" );
else
schema["default"] = current;
}
else
{
binding = createBinding( schema );
const QString key = detachedKey( field, keyword );
if ( detachedValues.contains( key ) ) schema["default"] = detachedValues.take( key );
}
binding->applySchema( schema );
addItem( binding );
}
for ( RimWorkflowFieldBinding* removed : existing )
{
if ( !removed ) continue;
const QJsonValue value = removed->toJsonValue();
if ( !value.isNull() ) detachedValues.insert( detachedKey( removed->fieldName(), removed->classKeyword() ), value );
delete removed;
}
}
//--------------------------------------------------------------------------------------------------
/// All values that are set, including references to ResInsight objects
//--------------------------------------------------------------------------------------------------
QJsonObject RimWorkflowTaskInput::jsonValues() const
{
QJsonObject values;
for ( const RimWorkflowFieldBinding* binding : items() )
{
const QJsonValue value = binding->toJsonValue();
if ( !value.isNull() ) values.insert( binding->fieldName(), value );
}
return values;
}
//--------------------------------------------------------------------------------------------------
/// Values that can be stored in input.yaml, without references to objects in the project
//--------------------------------------------------------------------------------------------------
QJsonObject RimWorkflowTaskInput::literalValues() const
{
QJsonObject values;
for ( const RimWorkflowFieldBinding* binding : items() )
{
if ( binding->isObjectReference() ) continue;
const QJsonValue value = binding->toJsonValue();
if ( !value.isNull() ) values.insert( binding->fieldName(), value );
}
return values;
}
//--------------------------------------------------------------------------------------------------
@@ -113,9 +243,12 @@ void RimWorkflowTaskInput::buildFromSchema( const QJsonArray& configFields )
//--------------------------------------------------------------------------------------------------
void RimWorkflowTaskInput::defineUiOrdering( QString uiConfigName, caf::PdmUiOrdering& uiOrdering )
{
for ( RimWorkflowFieldBinding* b : items() )
if ( !m_taskType().isEmpty() ) uiOrdering.add( &m_taskType );
if ( !m_taskDescription().isEmpty() ) uiOrdering.add( &m_taskDescription );
for ( RimWorkflowFieldBinding* binding : items() )
{
if ( b && b->valueField() ) uiOrdering.add( b->valueField() );
if ( binding && binding->valueField() ) uiOrdering.add( binding->valueField() );
}
uiOrdering.skipRemainingFields( true );
}
@@ -127,21 +260,3 @@ void RimWorkflowTaskInput::defineUiTreeOrdering( caf::PdmUiTreeOrdering& uiTreeO
{
uiTreeOrdering.skipRemainingChildren( true );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QString RimWorkflowTaskInput::toTaskYamlBlock() const
{
QString out = m_taskName() + ":\n";
if ( count() == 0 )
{
out += " {}\n";
return out;
}
for ( const RimWorkflowFieldBinding* b : items() )
{
out += QString( " %1: %2\n" ).arg( b->fieldName(), b->toYamlValue() );
}
return out;
}
@@ -15,15 +15,22 @@
// for more details.
//
/////////////////////////////////////////////////////////////////////////////////
#pragma once
#include "RimWorkflowFieldBinding.h"
#include "cafPdmObjectCollection.h"
#include <QJsonObject>
#include <QJsonValue>
#include <QMap>
class QJsonArray;
//==================================================================================================
/// The input values of one task instance in a job. There is one per task in the workflow; tasks
/// without config fields have no bindings and are hidden.
//==================================================================================================
class RimWorkflowTaskInput : public caf::PdmObjectCollection<RimWorkflowFieldBinding>
{
CAF_PDM_HEADER_INIT;
@@ -33,10 +40,18 @@ public:
QString taskName() const;
void setTaskName( const QString& name );
QString taskType() const;
void setTaskInfo( const QString& taskType, const QString& description );
void buildFromSchema( const QJsonArray& configFields );
void syncFromSchema( const QJsonArray& configFields, QMap<QString, QJsonValue>& detachedValues );
QString toTaskYamlBlock() const;
QJsonObject jsonValues() const;
QJsonObject literalValues() const;
static QString bindingClassKeyword( const QJsonObject& fieldSchema );
static RimWorkflowFieldBinding* createBinding( const QJsonObject& fieldSchema );
static QString detachedKey( const QString& fieldName, const QString& classKeyword );
protected:
void defineUiOrdering( QString uiConfigName, caf::PdmUiOrdering& uiOrdering ) override;
@@ -44,4 +59,6 @@ protected:
private:
caf::PdmField<QString> m_taskName;
caf::PdmField<QString> m_taskType;
caf::PdmField<QString> m_taskDescription;
};
@@ -0,0 +1,149 @@
/////////////////////////////////////////////////////////////////////////////////
//
// 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 "RimWorkflowValidationTools.h"
#include <QJsonArray>
namespace
{
RimWorkflowIssue errorIssue( const QString& task, const QString& field, const QString& message )
{
return { .task = task, .field = field, .severity = RimWorkflowIssue::Severity::Error, .message = message };
}
void appendErrorIssues( const QJsonObject& error, const QStringList& nodeNames, std::vector<RimWorkflowIssue>& issues )
{
QString message = error.value( "message" ).toString();
if ( message.isEmpty() ) message = "The workflow is not valid";
QString task = error.value( "task" ).toString();
if ( !nodeNames.contains( task ) ) task = RimWorkflowValidationTools::taskFromMessage( message, nodeNames );
const QJsonArray fieldIssues = error.value( "issues" ).toArray();
if ( fieldIssues.isEmpty() || task.isEmpty() )
{
issues.push_back( errorIssue( task, error.value( "field" ).toString(), message ) );
return;
}
for ( const QJsonValue& value : fieldIssues )
{
const QJsonObject fieldIssue = value.toObject();
const QString code = fieldIssue.value( "code" ).toString();
issues.push_back(
errorIssue( task, fieldIssue.value( "field" ).toString(), code.isEmpty() ? message : QString( "%1 (%2)" ).arg( message, code ) ) );
}
}
} // namespace
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
RimWorkflowValidationTools::Result RimWorkflowValidationTools::issuesFromHelperResult( const QJsonObject& helperResult,
const QStringList& nodeNames )
{
if ( helperResult.value( "status" ).toString() == "invalid" )
{
Result result;
result.describeSucceeded = false;
appendErrorIssues( helperResult.value( "error" ).toObject(), nodeNames, result.issues );
return result;
}
return issuesFromDescribe( helperResult.value( "describe" ).toObject(), helperResult.value( "describe_error" ).toObject(), nodeNames );
}
//--------------------------------------------------------------------------------------------------
/// Errors from describe, and warnings for config fields without a value
//--------------------------------------------------------------------------------------------------
RimWorkflowValidationTools::Result RimWorkflowValidationTools::issuesFromDescribe( const QJsonObject& describe,
const QJsonObject& describeError,
const QStringList& nodeNames )
{
Result result;
if ( !describeError.isEmpty() )
{
result.describeSucceeded = false;
appendErrorIssues( describeError, nodeNames, result.issues );
return result;
}
for ( const QJsonValue& value : describe.value( "tasks" ).toArray() )
{
const QJsonObject task = value.toObject();
for ( const QJsonValue& field : task.value( "missing_config_fields" ).toArray() )
{
result.issues.push_back( { .task = task.value( "name" ).toString(),
.field = field.toString(),
.severity = RimWorkflowIssue::Severity::Warning,
.message = missingValueMessage( field.toString() ) } );
}
}
return result;
}
//--------------------------------------------------------------------------------------------------
/// The node whose name is quoted in the message, preferring the longest name
//--------------------------------------------------------------------------------------------------
QString RimWorkflowValidationTools::taskFromMessage( const QString& message, const QStringList& nodeNames )
{
QString found;
for ( const QString& name : nodeNames )
{
const bool quoted = message.contains( QString( "'%1'" ).arg( name ) ) || message.contains( QString( "\"%1\"" ).arg( name ) );
if ( quoted && name.size() > found.size() ) found = name;
}
return found;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QString RimWorkflowValidationTools::missingValueMessage( const QString& fieldName )
{
return QString( "Input '%1' has no value" ).arg( fieldName );
}
//--------------------------------------------------------------------------------------------------
/// The saved definition only carries literal values, so describe reports object references (cases,
/// well paths, views) as missing, and the result goes stale when the user edits a value. The job's
/// own bindings are the source of truth for the fields they cover.
//--------------------------------------------------------------------------------------------------
QJsonArray RimWorkflowValidationTools::issuesWithJobValues( const QString& taskName,
const QJsonArray& taskIssues,
const std::map<QString, bool>& fieldHasValue )
{
QJsonArray result;
for ( const QJsonValue& value : taskIssues )
{
const QJsonObject issue = value.toObject();
const QString field = issue.value( "field" ).toString();
const bool isMissingValueWarning = issue.value( "severity" ).toString() == "warning" &&
issue.value( "message" ).toString() == missingValueMessage( field );
if ( isMissingValueWarning && fieldHasValue.contains( field ) ) continue;
result.append( issue );
}
for ( const auto& [field, hasValue] : fieldHasValue )
{
if ( hasValue ) continue;
result.append(
QJsonObject{ { "task", taskName }, { "field", field }, { "severity", "warning" }, { "message", missingValueMessage( field ) } } );
}
return result;
}
@@ -0,0 +1,50 @@
/////////////////////////////////////////////////////////////////////////////////
//
// 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.
//
/////////////////////////////////////////////////////////////////////////////////
#pragma once
#include "RimWorkflowDefinition.h"
#include <QJsonArray>
#include <QJsonObject>
#include <QStringList>
#include <map>
#include <vector>
//==================================================================================================
/// Turns the result of `taskmaestro_helper save --describe` (or `load --describe`) into issues
/// attached to the tasks of the workflow. Issues with an empty task belong to the workflow.
//==================================================================================================
namespace RimWorkflowValidationTools
{
struct Result
{
std::vector<RimWorkflowIssue> issues;
bool describeSucceeded = true;
};
Result issuesFromHelperResult( const QJsonObject& helperResult, const QStringList& nodeNames );
Result issuesFromDescribe( const QJsonObject& describe, const QJsonObject& describeError, const QStringList& nodeNames );
QString taskFromMessage( const QString& message, const QStringList& nodeNames );
QString missingValueMessage( const QString& fieldName );
// Replace the missing-value warnings of a task with warnings derived from the values of a job.
// `fieldHasValue` maps the task's bound input fields to whether the job gives them a value.
QJsonArray issuesWithJobValues( const QString& taskName, const QJsonArray& taskIssues, const std::map<QString, bool>& fieldHasValue );
} // namespace RimWorkflowValidationTools
@@ -50,13 +50,10 @@ void RimWorkflowVec3Binding::applySchema( const QJsonObject& fieldSchema )
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QString RimWorkflowVec3Binding::toYamlValue() const
QJsonValue RimWorkflowVec3Binding::toJsonValue() const
{
if ( !hasValue() ) return "null";
if ( !hasValue() ) return QJsonValue::Null;
const cvf::Vec3d& v = m_value();
return QString( "{x: %1, y: %2, z: %3}" )
.arg( QString::number( v.x(), 'g', 17 ) )
.arg( QString::number( v.y(), 'g', 17 ) )
.arg( QString::number( v.z(), 'g', 17 ) );
return QJsonObject{ { "x", v.x() }, { "y", v.y() }, { "z", v.z() } };
}
@@ -31,8 +31,8 @@ class RimWorkflowVec3Binding : public RimWorkflowFieldBinding
public:
RimWorkflowVec3Binding();
void applySchema( const QJsonObject& fieldSchema ) override;
QString toYamlValue() const override;
void applySchema( const QJsonObject& fieldSchema ) override;
QJsonValue toJsonValue() const override;
caf::PdmFieldHandle* valueField() override { return &m_value; }
@@ -23,6 +23,8 @@
#include "RimEclipseView.h"
#include "RimProject.h"
#include <QJsonObject>
CAF_PDM_SOURCE_INIT( RimWorkflowViewBinding, "WorkflowViewBinding" );
//--------------------------------------------------------------------------------------------------
@@ -44,10 +46,10 @@ QString RimWorkflowViewBinding::displayValue() const
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QString RimWorkflowViewBinding::toYamlValue() const
QJsonValue RimWorkflowViewBinding::toJsonValue() const
{
if ( m_view() == nullptr ) return "null";
return QString( "{__resinsight_ref__: View, view_id: %1}" ).arg( m_view()->id() );
if ( m_view() == nullptr ) return QJsonValue::Null;
return QJsonObject{ { "__resinsight_ref__", "View" }, { "view_id", m_view()->id() } };
}
//--------------------------------------------------------------------------------------------------
@@ -73,3 +75,31 @@ QList<caf::PdmOptionItemInfo> RimWorkflowViewBinding::calculateValueOptions( con
}
return options;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
bool RimWorkflowViewBinding::isObjectReference() const
{
return true;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RimWorkflowViewBinding::applySchema( const QJsonObject& fieldSchema )
{
RimWorkflowFieldBinding::applySchema( fieldSchema );
const QJsonObject reference = fieldSchema.value( "default" ).toObject();
if ( reference.value( "__resinsight_ref__" ).toString() != "View" || !reference.contains( "view_id" ) ) return;
const int viewId = reference.value( "view_id" ).toInt( -1 );
for ( RimEclipseCase* eclipseCase : RimEclipseCaseTools::eclipseCases() )
{
if ( !eclipseCase ) continue;
for ( RimEclipseView* view : eclipseCase->reservoirViews() )
{
if ( view && view->id() == viewId ) m_view = view;
}
}
}
@@ -31,8 +31,10 @@ class RimWorkflowViewBinding : public RimWorkflowFieldBinding
public:
RimWorkflowViewBinding();
QString toYamlValue() const override;
QString displayValue() const override;
QJsonValue toJsonValue() const override;
bool isObjectReference() const override;
void applySchema( const QJsonObject& fieldSchema ) override;
QString displayValue() const override;
caf::PdmFieldHandle* valueField() override { return &m_view; }
@@ -18,9 +18,12 @@
#include "RimWorkflowWellPathBinding.h"
#include "RimProject.h"
#include "RimTools.h"
#include "RimWellPath.h"
#include <QJsonObject>
CAF_PDM_SOURCE_INIT( RimWorkflowWellPathBinding, "WorkflowWellPathBinding" );
//--------------------------------------------------------------------------------------------------
@@ -42,11 +45,10 @@ QString RimWorkflowWellPathBinding::displayValue() const
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QString RimWorkflowWellPathBinding::toYamlValue() const
QJsonValue RimWorkflowWellPathBinding::toJsonValue() const
{
if ( m_wellPath() == nullptr ) return "null";
const QString name = yamlQuotedScalar( m_wellPath()->name() );
return QString( "{__resinsight_ref__: WellPath, well_path_name: %1}" ).arg( name );
if ( m_wellPath() == nullptr ) return QJsonValue::Null;
return QJsonObject{ { "__resinsight_ref__", "WellPath" }, { "well_path_name", m_wellPath()->name() } };
}
//--------------------------------------------------------------------------------------------------
@@ -58,3 +60,29 @@ QList<caf::PdmOptionItemInfo> RimWorkflowWellPathBinding::calculateValueOptions(
if ( fieldNeedingOptions == &m_wellPath ) RimTools::wellPathOptionItems( &options );
return options;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
bool RimWorkflowWellPathBinding::isObjectReference() const
{
return true;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RimWorkflowWellPathBinding::applySchema( const QJsonObject& fieldSchema )
{
RimWorkflowFieldBinding::applySchema( fieldSchema );
const QJsonObject reference = fieldSchema.value( "default" ).toObject();
if ( reference.value( "__resinsight_ref__" ).toString() != "WellPath" || !reference.contains( "well_path_name" ) ) return;
auto* project = RimProject::current();
if ( !project ) return;
const QString name = reference.value( "well_path_name" ).toString();
for ( RimWellPath* wellPath : project->allWellPaths() )
{
if ( wellPath && wellPath->name() == name ) m_wellPath = wellPath;
}
}
@@ -31,8 +31,10 @@ class RimWorkflowWellPathBinding : public RimWorkflowFieldBinding
public:
RimWorkflowWellPathBinding();
QString toYamlValue() const override;
QString displayValue() const override;
QJsonValue toJsonValue() const override;
bool isObjectReference() const override;
void applySchema( const QJsonObject& fieldSchema ) override;
QString displayValue() const override;
caf::PdmFieldHandle* valueField() override { return &m_wellPath; }