Files
ResInsight/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.cpp
T
Kristian Bendiksen e6dc62a160 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
2026-10-23 20:23:54 +02:00

861 lines
36 KiB
C++

/////////////////////////////////////////////////////////////////////////////////
//
// Copyright (C) 2026 Equinor ASA
//
// ResInsight is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or
// FITNESS FOR A PARTICULAR PURPOSE.
//
// See the GNU General Public License at <http://www.gnu.org/licenses/gpl.html>
// for more details.
//
/////////////////////////////////////////////////////////////////////////////////
#include "RimWorkflow.h"
#include "RimWorkflowDefinitionTools.h"
#include "RimWorkflowDescribeTools.h"
#include "RimWorkflowHelperProcess.h"
#include "RimWorkflowJob.h"
#include "RimWorkflowValidationTools.h"
#include "RiaLogging.h"
#include "RiaPreferences.h"
#include "RiuMainWindow.h"
#include "cafCmdFeatureMenuBuilder.h"
#include "cafPdmUiOrdering.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QStandardPaths>
#include <QTemporaryDir>
CAF_PDM_SOURCE_INIT( RimWorkflow, "Workflow" );
namespace
{
constexpr size_t maxUndoSteps = 100;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
RimWorkflow::RimWorkflow()
{
CAF_PDM_InitObject( "Workflow", ":/Folder.png" );
CAF_PDM_InitFieldNoDefault( &m_name, "Name", "Name" );
m_name.uiCapability()->setUiReadOnly( true );
CAF_PDM_InitFieldNoDefault( &m_description, "Description", "Description" );
m_description.uiCapability()->setUiReadOnly( true );
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;
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QString RimWorkflow::name() const
{
return m_name();
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RimWorkflow::setWorkflowDirectory( const QString& directory )
{
m_workflowDirectory = directory;
m_sourceDirectory = directory;
m_name = QFileInfo( QDir( directory ).absolutePath() ).fileName();
updateUiName();
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QString RimWorkflow::workflowDirectory() const
{
return m_workflowDirectory().path();
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QJsonObject RimWorkflow::graph() const
{
return m_graph;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QString RimWorkflow::loadError() const
{
return m_loadError();
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
std::vector<RimWorkflowJob*> RimWorkflow::jobs() const
{
std::vector<RimWorkflowJob*> result;
result.reserve( m_jobs.size() );
for ( RimWorkflowJob* job : m_jobs.childrenByType() )
{
if ( job ) result.push_back( job );
}
return result;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RimWorkflow::addJob( RimWorkflowJob* 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 );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
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_graph = {};
m_loadError = "";
const QString directory = workflowDirectory();
if ( directory.isEmpty() ) return false;
if ( !QDir( directory ).exists( "workflow.yaml" ) ) return setLoadError( "Missing workflow.yaml", errorMessage );
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 = RimWorkflowHelperProcess::errorMessage( m_definition.describeError );
RiaLogging::warning( QString( "Workflow '%1' is not valid: %2" ).arg( m_name(), m_loadError() ).toStdString() );
}
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() )
{
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;
}
// `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() )
{
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();
setDefinition( definition, Source::Registered );
if ( m_graph.value( "tasks" ).toArray().isEmpty() ) return setLoadError( "The workflow could not be described", errorMessage );
return true;
}
//--------------------------------------------------------------------------------------------------
/// 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;
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 );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
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;
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() )
{
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" );
m_jobs.push_back( job );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RimWorkflow::setValidationResult( int revision, const std::vector<RimWorkflowIssue>& issues, bool describeSucceeded )
{
if ( revision != m_revision ) return;
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 {};
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QString RimWorkflow::findPythonExecutable()
{
QStringList candidates;
if ( auto* prefs = RiaPreferences::current() )
{
QString configured = prefs->pythonExecutable();
if ( !configured.isEmpty() && configured != "python" ) candidates << configured;
}
candidates << "python3" << "python";
for ( const QString& candidate : candidates )
{
if ( candidate.contains( '/' ) || candidate.contains( '\\' ) )
{
if ( QFileInfo( candidate ).isExecutable() ) return candidate;
}
else if ( !QStandardPaths::findExecutable( candidate ).isEmpty() )
{
return candidate;
}
}
return {};
}