///////////////////////////////////////////////////////////////////////////////// // // 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 // 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 #include #include #include #include #include #include #include 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", "" ); CAF_PDM_InitField( &m_inlineConfigValues, "InlineConfigValues", true, "Inline Config Values" ); m_inlineConfigValues.uiCapability()->setUiHidden( true ); CAF_PDM_InitField( &m_verticalLayout, "VerticalLayout", false, "Vertical Layout" ); m_verticalLayout.uiCapability()->setUiHidden( true ); CAF_PDM_InitFieldNoDefault( &m_hiddenTasks, "HiddenTasks", "Hidden Tasks" ); m_hiddenTasks.uiCapability()->setUiHidden( true ); CAF_PDM_InitFieldNoDefault( &m_collapsedTasks, "CollapsedTasks", "Collapsed Tasks" ); m_collapsedTasks.uiCapability()->setUiHidden( true ); CAF_PDM_InitFieldNoDefault( &m_nestedWorkflows, "NestedWorkflows", "Nested Workflows" ); m_nestedWorkflows.uiCapability()->setUiTreeChildrenHidden( true ); m_nestedWorkflows.xmlCapability()->disableIO(); } //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- 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(); } //-------------------------------------------------------------------------------------------------- /// Whether the graph shows config values in the task boxes instead of in separate config boxes //-------------------------------------------------------------------------------------------------- bool RimWorkflow::inlineConfigValues() const { return m_inlineConfigValues(); } //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- void RimWorkflow::setInlineConfigValues( bool inlineValues ) { m_inlineConfigValues = inlineValues; } //-------------------------------------------------------------------------------------------------- /// Whether the graph flows from top to bottom instead of from left to right //-------------------------------------------------------------------------------------------------- bool RimWorkflow::verticalLayout() const { return m_verticalLayout(); } //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- void RimWorkflow::setVerticalLayout( bool vertical ) { m_verticalLayout = vertical; } //-------------------------------------------------------------------------------------------------- /// Tasks the user has hidden in the graph. A view setting: the definition is not changed. //-------------------------------------------------------------------------------------------------- QStringList RimWorkflow::hiddenTasks() const { return QStringList( m_hiddenTasks().begin(), m_hiddenTasks().end() ); } //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- bool RimWorkflow::isTaskHidden( const QString& taskName ) const { return std::ranges::contains( m_hiddenTasks(), taskName ); } //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- void RimWorkflow::setTaskHidden( const QString& taskName, bool hidden ) { std::vector tasks = m_hiddenTasks(); std::erase( tasks, taskName ); if ( hidden ) tasks.push_back( taskName ); m_hiddenTasks = tasks; } //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- void RimWorkflow::showAllTasks() { m_hiddenTasks = std::vector(); } //-------------------------------------------------------------------------------------------------- /// Tasks the user has collapsed to their title in the graph. A view setting like the hidden tasks. //-------------------------------------------------------------------------------------------------- QStringList RimWorkflow::collapsedTasks() const { return QStringList( m_collapsedTasks().begin(), m_collapsedTasks().end() ); } //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- bool RimWorkflow::isTaskCollapsed( const QString& taskName ) const { return std::ranges::contains( m_collapsedTasks(), taskName ); } //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- void RimWorkflow::setTaskCollapsed( const QString& taskName, bool collapsed ) { std::vector tasks = m_collapsedTasks(); std::erase( tasks, taskName ); if ( collapsed ) tasks.push_back( taskName ); m_collapsedTasks = tasks; } //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- void RimWorkflow::setCollapsedTasks( const QStringList& taskNames ) { std::vector tasks; for ( const QString& taskName : taskNames ) { if ( !std::ranges::contains( tasks, taskName ) ) tasks.push_back( taskName ); } m_collapsedTasks = tasks; } //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- std::vector RimWorkflow::jobs() const { std::vector 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& 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; } //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- bool RimWorkflow::isNestedTask( const QString& taskName ) const { for ( const QJsonValue& task : m_graph.value( "tasks" ).toArray() ) { if ( task.toObject().value( "name" ).toString() == taskName ) return task.toObject().value( "nested_workflow" ).isObject(); } return false; } //-------------------------------------------------------------------------------------------------- /// The workflow wrapped by a task, loaded the first time it is asked for. Null if the task is not /// a nested workflow. A workflow that cannot be loaded has a load error. //-------------------------------------------------------------------------------------------------- RimWorkflow* RimWorkflow::nestedWorkflow( const QString& taskName ) { for ( RimWorkflow* nested : m_nestedWorkflows.childrenByType() ) { if ( nested->m_nestedTaskName == taskName ) return nested; } if ( !isNestedTask( taskName ) ) return nullptr; auto* nested = new RimWorkflow; nested->m_nestedTaskName = taskName; nested->m_name = taskName; m_nestedWorkflows.push_back( nested ); nested->loadNested(); return nested; } //-------------------------------------------------------------------------------------------------- /// The workflow a nested workflow belongs to //-------------------------------------------------------------------------------------------------- RimWorkflow* RimWorkflow::parentWorkflow() const { return m_source == Source::Nested ? firstAncestorOfType() : nullptr; } //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- QString RimWorkflow::nestedTaskName() const { return m_nestedTaskName; } //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- QStringList RimWorkflow::nestedTaskPath() const { QStringList path; for ( const RimWorkflow* workflow = this; workflow->parentWorkflow(); workflow = workflow->parentWorkflow() ) path.prepend( workflow->m_nestedTaskName ); return path; } //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- QMap RimWorkflow::nestedEntries() const { return m_source == Source::Nested ? m_nestedEntries : RimWorkflowDefinitionTools::nestedWorkflowEntries( m_definition ); } //-------------------------------------------------------------------------------------------------- /// Describe the workflow of a nested task through `taskmaestro_helper describe-nested`. A YAML /// entry is described from its file, relative to the file of the entry. A nested workflow in a /// registered workflow is found from the root workflow and the task path. //-------------------------------------------------------------------------------------------------- void RimWorkflow::loadNested() { m_source = Source::Nested; RimWorkflowDefinition definition; definition.name = m_nestedTaskName; definition.editable = false; definition.readOnlyReasons.append( QString( "Nested workflow of task '%1'" ).arg( m_nestedTaskName ) ); const auto failed = [&]( const QString& message ) { setDefinition( definition, Source::Nested ); setLoadError( message, nullptr ); }; const std::optional args = describeNestedArguments(); if ( !args ) return failed( QString( "Cannot find the workflow of nested task '%1'" ).arg( m_nestedTaskName ) ); auto output = RimWorkflowHelperProcess::runSync( *args ); if ( !output ) return failed( output.error() ); setNestedDescription( *output, definition ); } //-------------------------------------------------------------------------------------------------- /// Arguments to `taskmaestro_helper describe-nested` for this nested workflow. A nested workflow in a /// registered workflow is found from the root workflow and the task path, otherwise from the YAML /// file of the entry. Empty if the workflow cannot be found. //-------------------------------------------------------------------------------------------------- std::optional RimWorkflow::describeNestedArguments() { RimWorkflow* parent = firstAncestorOfType(); if ( !parent ) return std::nullopt; RimWorkflow* root = parent; while ( root->parentWorkflow() ) root = root->parentWorkflow(); if ( root->source() != Source::Registered ) return describeNestedYamlArguments( *parent ); QStringList args{ "describe-nested", "--registered", root->registeredId() }; for ( const QString& task : nestedTaskPath() ) args << "--task" << task; return args; } //-------------------------------------------------------------------------------------------------- /// Arguments to describe a `workflow:` entry of the parent from its YAML file, relative to the /// directory of the parent. Sets the source directory to the directory of the YAML file. //-------------------------------------------------------------------------------------------------- std::optional RimWorkflow::describeNestedYamlArguments( const RimWorkflow& parent ) { const QJsonObject entry = parent.nestedEntries().value( m_nestedTaskName ); const QString file = entry.value( "workflow" ).toString(); if ( file.isEmpty() || parent.sourceDirectory().isEmpty() ) return std::nullopt; const QDir directory( parent.sourceDirectory() ); const QString path = QDir::cleanPath( directory.absoluteFilePath( file ) ); QStringList args{ "describe-nested", "--yaml", path }; const QString input = entry.value( "workflow_input" ).toString(); if ( !input.isEmpty() ) args << "--input" << QDir::cleanPath( directory.absoluteFilePath( input ) ); // An empty import directory would resolve to the current working directory for ( const RimWorkflow* workflow = &parent; workflow; workflow = workflow->parentWorkflow() ) { if ( !workflow->sourceDirectory().isEmpty() ) args << "--import-dir" << workflow->sourceDirectory(); } m_sourceDirectory = QFileInfo( path ).absolutePath(); return args; } //-------------------------------------------------------------------------------------------------- /// Set the definition and nested entries from the output of `taskmaestro_helper describe-nested` //-------------------------------------------------------------------------------------------------- void RimWorkflow::setNestedDescription( const QJsonObject& output, RimWorkflowDefinition definition ) { const QJsonObject nested = output.value( "nested" ).toObject(); m_nestedEntries.clear(); for ( auto it = nested.begin(); it != nested.end(); ++it ) m_nestedEntries.insert( it.key(), it.value().toObject() ); definition.name = output.value( "name" ).toString( m_nestedTaskName ); definition.describe = output.value( "describe" ).toObject(); definition.describeError = output.value( "describe_error" ).toObject(); setDefinition( definition, Source::Nested ); if ( !definition.describe.isEmpty() ) return; const QString message = definition.describeError.isEmpty() ? QString( "The nested workflow could not be described" ) : RimWorkflowHelperProcess::errorMessage( definition.describeError ); setLoadError( message, nullptr ); } //-------------------------------------------------------------------------------------------------- /// 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 ) { // Nested workflows are loaded again when opened, from the new definition m_nestedWorkflows.deleteChildren(); 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 && m_source != Source::Nested; } //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- 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 RimWorkflow::applyEdit( const std::expected& result, const QMap& 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 RimWorkflow::undoRenames() const { return m_undoStack.empty() ? QMap() : invertedRenames( m_undoStack.back().renames ); } //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- QMap RimWorkflow::redoRenames() const { return m_redoStack.empty() ? QMap() : 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& renames ) { ++m_revision; m_name = m_definition.name; m_description = m_definition.headerComment; // Hidden and collapsed tasks follow renames, and deleted tasks are forgotten for ( auto* field : { &m_hiddenTasks, &m_collapsedTasks } ) { std::vector tasks; for ( const QString& taskName : field->v() ) { const QString name = renames.value( taskName, taskName ); if ( m_definition.findNode( name ) && !std::ranges::contains( tasks, name ) ) tasks.push_back( name ); } *field = tasks; } // 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 RimWorkflow::invertedRenames( const QMap& renames ) { QMap 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 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 ); } m_graph = RimWorkflowDefinitionTools::markNestedWorkflows( m_graph, nestedEntries(), m_definition.describe ); } //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- void RimWorkflow::updateUiName() { setUiName( isDirty() && isEditable() ? m_name() + " *" : m_name() ); } //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- void RimWorkflow::ensureDefaultJob() { // A nested workflow runs as one task of its parent's jobs if ( !m_jobs.empty() || m_source == Source::Nested ) return; auto* job = new RimWorkflowJob; job->setJobName( "Default" ); m_jobs.push_back( job ); } //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- void RimWorkflow::setValidationResult( int revision, const std::vector& issues, bool describeSucceeded ) { if ( revision != m_revision ) return; m_validationIssues = issues; m_validationRevision = revision; m_describeSucceeded = describeSucceeded; rebuildGraph(); notifyDefinitionChanged(); } //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- std::vector 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 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 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 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 RimWorkflow::editDirectory() { if ( !m_editDirectory ) { m_editDirectory = std::make_unique( 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::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 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 {}; }