diff --git a/ApplicationLibCode/CMakeLists.txt b/ApplicationLibCode/CMakeLists.txt index 1b11e524e75..8846594c2fd 100644 --- a/ApplicationLibCode/CMakeLists.txt +++ b/ApplicationLibCode/CMakeLists.txt @@ -127,6 +127,7 @@ list( ProjectDataModel/CellFilters/CMakeLists_files.cmake ProjectDataModel/ProcessControl/CMakeLists_files.cmake ProjectDataModel/Polygons/CMakeLists_files.cmake + ProjectDataModel/Workflow/CMakeLists_files.cmake ProjectDataModel/WellLog/CMakeLists_files.cmake ProjectDataModel/WellLog/WellLogTrack/CMakeLists_files.cmake ProjectDataModel/WellMeasurement/CMakeLists_files.cmake diff --git a/ApplicationLibCode/Commands/CMakeLists.txt b/ApplicationLibCode/Commands/CMakeLists.txt index 2189f63d983..2883d1335a8 100644 --- a/ApplicationLibCode/Commands/CMakeLists.txt +++ b/ApplicationLibCode/Commands/CMakeLists.txt @@ -60,6 +60,7 @@ set(COMMAND_REFERENCED_CMAKE_FILES FractureCommands/CMakeLists_files.cmake PlotBuilderCommands/CMakeLists_files.cmake PolygonCommands/CMakeLists_files.cmake + WorkflowCommands/CMakeLists_files.cmake Sumo/CMakeLists_files.cmake ToolCommands/CMakeLists_files.cmake 3dView/CMakeLists_files.cmake diff --git a/ApplicationLibCode/Commands/WorkflowCommands/CMakeLists_files.cmake b/ApplicationLibCode/Commands/WorkflowCommands/CMakeLists_files.cmake new file mode 100644 index 00000000000..1a969ad9329 --- /dev/null +++ b/ApplicationLibCode/Commands/WorkflowCommands/CMakeLists_files.cmake @@ -0,0 +1,14 @@ +set(SOURCE_GROUP_HEADER_FILES + ${CMAKE_CURRENT_LIST_DIR}/RicNewWorkflowJobFeature.h + ${CMAKE_CURRENT_LIST_DIR}/RicRunWorkflowJobFeature.h + ${CMAKE_CURRENT_LIST_DIR}/RicCancelWorkflowJobFeature.h +) + +set(SOURCE_GROUP_SOURCE_FILES + ${CMAKE_CURRENT_LIST_DIR}/RicNewWorkflowJobFeature.cpp + ${CMAKE_CURRENT_LIST_DIR}/RicRunWorkflowJobFeature.cpp + ${CMAKE_CURRENT_LIST_DIR}/RicCancelWorkflowJobFeature.cpp +) + +list(APPEND COMMAND_CODE_HEADER_FILES ${SOURCE_GROUP_HEADER_FILES}) +list(APPEND COMMAND_CODE_SOURCE_FILES ${SOURCE_GROUP_SOURCE_FILES}) diff --git a/ApplicationLibCode/Commands/WorkflowCommands/RicCancelWorkflowJobFeature.cpp b/ApplicationLibCode/Commands/WorkflowCommands/RicCancelWorkflowJobFeature.cpp new file mode 100644 index 00000000000..639c6f6c0d3 --- /dev/null +++ b/ApplicationLibCode/Commands/WorkflowCommands/RicCancelWorkflowJobFeature.cpp @@ -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 +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#include "RicCancelWorkflowJobFeature.h" + +#include "Workflow/RimWorkflowJob.h" + +#include "cafSelectionManagerTools.h" + +#include + +CAF_CMD_SOURCE_INIT( RicCancelWorkflowJobFeature, "RicCancelWorkflowJobFeature" ); + +void RicCancelWorkflowJobFeature::onActionTriggered( bool isChecked ) +{ + auto jobs = caf::selectedObjectsByType(); + if ( jobs.size() != 1 ) return; + jobs.front()->cancelJob(); +} + +void RicCancelWorkflowJobFeature::setupActionLook( QAction* actionToSetup ) +{ + actionToSetup->setText( "Cancel Job" ); + actionToSetup->setIcon( QIcon( ":/stop.svg" ) ); +} + +bool RicCancelWorkflowJobFeature::isCommandEnabled() const +{ + auto jobs = caf::selectedObjectsByType(); + return jobs.size() == 1 && jobs.front()->isRunning(); +} diff --git a/ApplicationLibCode/Commands/WorkflowCommands/RicCancelWorkflowJobFeature.h b/ApplicationLibCode/Commands/WorkflowCommands/RicCancelWorkflowJobFeature.h new file mode 100644 index 00000000000..e89abd3d245 --- /dev/null +++ b/ApplicationLibCode/Commands/WorkflowCommands/RicCancelWorkflowJobFeature.h @@ -0,0 +1,31 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "cafCmdFeature.h" + +class RicCancelWorkflowJobFeature : public caf::CmdFeature +{ + CAF_CMD_HEADER_INIT; + +protected: + void onActionTriggered( bool isChecked ) override; + void setupActionLook( QAction* actionToSetup ) override; + bool isCommandEnabled() const override; +}; diff --git a/ApplicationLibCode/Commands/WorkflowCommands/RicNewWorkflowJobFeature.cpp b/ApplicationLibCode/Commands/WorkflowCommands/RicNewWorkflowJobFeature.cpp new file mode 100644 index 00000000000..e6eee0523af --- /dev/null +++ b/ApplicationLibCode/Commands/WorkflowCommands/RicNewWorkflowJobFeature.cpp @@ -0,0 +1,65 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RicNewWorkflowJobFeature.h" + +#include "Workflow/RimWorkflow.h" +#include "Workflow/RimWorkflowJob.h" + +#include "RiaLogging.h" + +#include "cafPdmDefaultObjectFactory.h" +#include "cafSelectionManagerTools.h" + +#include + +CAF_CMD_SOURCE_INIT( RicNewWorkflowJobFeature, "RicNewWorkflowJobFeature" ); + +void RicNewWorkflowJobFeature::onActionTriggered( bool isChecked ) +{ + auto workflows = caf::selectedObjectsByType(); + if ( workflows.size() != 1 ) return; + + RimWorkflow* workflow = workflows.front(); + auto jobs = workflow->jobs(); + if ( jobs.empty() ) + { + RiaLogging::warning( "Cannot create a new job: workflow has no template job to clone." ); + return; + } + + auto* clone = + dynamic_cast( jobs.front()->xmlCapability()->copyByXmlSerialization( caf::PdmDefaultObjectFactory::instance() ) ); + if ( !clone ) return; + + clone->setJobName( QString( "Job %1" ).arg( jobs.size() + 1 ) ); + workflow->addJob( clone ); + clone->resolveReferencesRecursively(); + workflow->uiCapability()->updateAllRequiredEditors(); +} + +void RicNewWorkflowJobFeature::setupActionLook( QAction* actionToSetup ) +{ + actionToSetup->setText( "New Job" ); + actionToSetup->setIcon( QIcon( ":/caf/duplicate.svg" ) ); +} + +bool RicNewWorkflowJobFeature::isCommandEnabled() const +{ + return caf::selectedObjectsByType().size() == 1; +} diff --git a/ApplicationLibCode/Commands/WorkflowCommands/RicNewWorkflowJobFeature.h b/ApplicationLibCode/Commands/WorkflowCommands/RicNewWorkflowJobFeature.h new file mode 100644 index 00000000000..b0249b0a3a0 --- /dev/null +++ b/ApplicationLibCode/Commands/WorkflowCommands/RicNewWorkflowJobFeature.h @@ -0,0 +1,31 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "cafCmdFeature.h" + +class RicNewWorkflowJobFeature : public caf::CmdFeature +{ + CAF_CMD_HEADER_INIT; + +protected: + void onActionTriggered( bool isChecked ) override; + void setupActionLook( QAction* actionToSetup ) override; + bool isCommandEnabled() const override; +}; diff --git a/ApplicationLibCode/Commands/WorkflowCommands/RicRunWorkflowJobFeature.cpp b/ApplicationLibCode/Commands/WorkflowCommands/RicRunWorkflowJobFeature.cpp new file mode 100644 index 00000000000..8da5a00c768 --- /dev/null +++ b/ApplicationLibCode/Commands/WorkflowCommands/RicRunWorkflowJobFeature.cpp @@ -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 +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#include "RicRunWorkflowJobFeature.h" + +#include "Workflow/RimWorkflowJob.h" + +#include "cafSelectionManagerTools.h" + +#include + +CAF_CMD_SOURCE_INIT( RicRunWorkflowJobFeature, "RicRunWorkflowJobFeature" ); + +void RicRunWorkflowJobFeature::onActionTriggered( bool isChecked ) +{ + auto jobs = caf::selectedObjectsByType(); + if ( jobs.size() != 1 ) return; + jobs.front()->runJob(); +} + +void RicRunWorkflowJobFeature::setupActionLook( QAction* actionToSetup ) +{ + actionToSetup->setText( "Run" ); + actionToSetup->setIcon( QIcon( ":/Play.svg" ) ); +} + +bool RicRunWorkflowJobFeature::isCommandEnabled() const +{ + auto jobs = caf::selectedObjectsByType(); + return jobs.size() == 1 && !jobs.front()->isRunning(); +} diff --git a/ApplicationLibCode/Commands/WorkflowCommands/RicRunWorkflowJobFeature.h b/ApplicationLibCode/Commands/WorkflowCommands/RicRunWorkflowJobFeature.h new file mode 100644 index 00000000000..0a6fd8ef822 --- /dev/null +++ b/ApplicationLibCode/Commands/WorkflowCommands/RicRunWorkflowJobFeature.h @@ -0,0 +1,31 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "cafCmdFeature.h" + +class RicRunWorkflowJobFeature : public caf::CmdFeature +{ + CAF_CMD_HEADER_INIT; + +protected: + void onActionTriggered( bool isChecked ) override; + void setupActionLook( QAction* actionToSetup ) override; + bool isCommandEnabled() const override; +}; diff --git a/ApplicationLibCode/ProjectDataModel/RimProject.cpp b/ApplicationLibCode/ProjectDataModel/RimProject.cpp index e1d0d4c06dc..0d519d79027 100644 --- a/ApplicationLibCode/ProjectDataModel/RimProject.cpp +++ b/ApplicationLibCode/ProjectDataModel/RimProject.cpp @@ -101,6 +101,7 @@ #include "Tools/RimAutomationSettings.h" #include "VerticalFlowPerformance/RimVfpDataCollection.h" #include "VerticalFlowPerformance/RimVfpPlotCollection.h" +#include "Workflow/RimWorkflowCollection.h" #include "RiuPlotMainWindow.h" @@ -136,6 +137,9 @@ RimProject::RimProject() CAF_PDM_InitFieldNoDefault( &scriptCollection, "ScriptCollection", "Octave Scripts", ":/octave.png" ); scriptCollection.xmlCapability()->disableIO(); + CAF_PDM_InitFieldNoDefault( &workflowCollection, "WorkflowCollection", "Workflows", ":/Folder.png" ); + workflowCollection.xmlCapability()->disableIO(); + CAF_PDM_InitFieldNoDefault( &m_jobCollection, "JobCollection", "Jobs", ":/gear.png" ); CAF_PDM_InitFieldNoDefault( &m_mainPlotCollection, "MainPlotCollection", "Plots" ); @@ -200,6 +204,9 @@ RimProject::RimProject() scriptCollection->uiCapability()->setUiName( "Scripts" ); scriptCollection->uiCapability()->setUiIconFromResourceString( ":/octave.png" ); + workflowCollection = new RimWorkflowCollection(); + workflowCollection->uiCapability()->setUiName( "Workflows" ); + m_mainPlotCollection = new RimMainPlotCollection(); m_pinnedFieldCollection = new RimQuickAccessCollection(); m_jobCollection = new RimJobCollection(); @@ -1484,6 +1491,7 @@ void RimProject::defineUiTreeOrdering( caf::PdmUiTreeOrdering& uiTreeOrdering, Q { uiTreeOrdering.add( scriptCollection() ); uiTreeOrdering.add( jobCollection() ); + uiTreeOrdering.add( workflowCollection() ); } else if ( uiConfigName == "PlotWindow.Templates" ) { diff --git a/ApplicationLibCode/ProjectDataModel/RimProject.h b/ApplicationLibCode/ProjectDataModel/RimProject.h index 25e592e4212..34b1fdd876d 100644 --- a/ApplicationLibCode/ProjectDataModel/RimProject.h +++ b/ApplicationLibCode/ProjectDataModel/RimProject.h @@ -53,6 +53,7 @@ class RimObservedSummaryData; class RimOilField; class RimColorLegendCollection; class RimScriptCollection; +class RimWorkflowCollection; class RimSummaryCase; class RimSummaryEnsemble; class RimSummaryCaseMainCollection; @@ -99,6 +100,7 @@ class RimProject : public caf::PdmDocument caf::PdmChildArrayField oilFields; caf::PdmChildField colorLegendCollection; caf::PdmChildField scriptCollection; + caf::PdmChildField workflowCollection; caf::PdmChildField viewLinkerCollection; caf::PdmChildField calculationCollection; caf::PdmChildField gridCalculationCollection; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/CMakeLists_files.cmake b/ApplicationLibCode/ProjectDataModel/Workflow/CMakeLists_files.cmake new file mode 100644 index 00000000000..42a9f7bc119 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/CMakeLists_files.cmake @@ -0,0 +1,43 @@ +set(SOURCE_GROUP_HEADER_FILES + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflow.h + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowCollection.h + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowJob.h + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowTaskInput.h + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowFieldBinding.h + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowStringBinding.h + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowFloatBinding.h + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowIntBinding.h + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowBoolBinding.h + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowCaseBinding.h + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowWellPathBinding.h + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowViewBinding.h + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowDateBinding.h + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowFilePathBinding.h +) + +set(SOURCE_GROUP_SOURCE_FILES + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflow.cpp + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowCollection.cpp + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowJob.cpp + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowTaskInput.cpp + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowFieldBinding.cpp + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowStringBinding.cpp + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowFloatBinding.cpp + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowIntBinding.cpp + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowBoolBinding.cpp + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowCaseBinding.cpp + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowWellPathBinding.cpp + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowViewBinding.cpp + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowDateBinding.cpp + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowFilePathBinding.cpp +) + +list(APPEND CODE_HEADER_FILES ${SOURCE_GROUP_HEADER_FILES}) + +list(APPEND CODE_SOURCE_FILES ${SOURCE_GROUP_SOURCE_FILES}) + +source_group( + "ProjectDataModel\\Workflow" + FILES ${SOURCE_GROUP_HEADER_FILES} ${SOURCE_GROUP_SOURCE_FILES} + ${CMAKE_CURRENT_LIST_DIR}/CMakeLists_files.cmake +) diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.cpp new file mode 100644 index 00000000000..935cd04a92d --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.cpp @@ -0,0 +1,204 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RimWorkflowJob.h" +#include "RimWorkflowTaskInput.h" + +#include "RiaLogging.h" +#include "RiaPreferences.h" + +#include "cafCmdFeatureMenuBuilder.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +CAF_PDM_SOURCE_INIT( RimWorkflow, "Workflow" ); + +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_loadError, "LoadError", "Load Error" ); + m_loadError.uiCapability()->setUiReadOnly( true ); + + CAF_PDM_InitFieldNoDefault( &m_jobs, "Jobs", "" ); +} + +QString RimWorkflow::name() const +{ + return m_name(); +} + +void RimWorkflow::setWorkflowDirectory( const QString& directory ) +{ + m_workflowDirectory = directory; + QString name = QFileInfo( QDir( directory ).absolutePath() ).fileName(); + m_name = name; + setUiName( name ); +} + +QString RimWorkflow::workflowDirectory() const +{ + return m_workflowDirectory().path(); +} + +std::vector RimWorkflow::jobs() const +{ + std::vector result; + result.reserve( m_jobs.size() ); + for ( RimWorkflowJob* j : m_jobs.childrenByType() ) + { + if ( j ) result.push_back( j ); + } + return result; +} + +void RimWorkflow::addJob( RimWorkflowJob* job ) +{ + if ( job ) m_jobs.push_back( job ); +} + +void RimWorkflow::appendMenuItems( caf::CmdFeatureMenuBuilder& menuBuilder ) const +{ + menuBuilder << "RicNewWorkflowJobFeature"; +} + +bool RimWorkflow::loadFromDirectory( QString* errorMessage ) +{ + m_jobs.deleteChildren(); + m_loadError = ""; + + const QString dir = workflowDirectory(); + if ( dir.isEmpty() ) return false; + + QString python = RimWorkflow::findPythonExecutable(); + if ( python.isEmpty() ) + { + 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; + } + + QStringList args{ "-m", "rips.taskmaestro_helper", "introspect", dir }; + + QProcess proc; + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + proc.setProcessEnvironment( env ); + proc.start( python, args ); + if ( !proc.waitForStarted( 10000 ) ) + { + 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 = "Introspect helper timed out"; + RiaLogging::warning( QString( "Workflow '%1': %2" ).arg( dir, m_loadError() ).toStdString() ); + if ( errorMessage ) *errorMessage = m_loadError; + return false; + } + + const QByteArray stdoutBytes = proc.readAllStandardOutput(); + if ( proc.exitStatus() != QProcess::NormalExit || proc.exitCode() != 0 ) + { + m_loadError = QString::fromUtf8( proc.readAllStandardError() ).trimmed(); + if ( m_loadError().isEmpty() ) m_loadError = "Introspect helper failed"; + RiaLogging::warning( QString( "Workflow '%1' introspect failed: %2" ).arg( dir, m_loadError() ).toStdString() ); + if ( errorMessage ) *errorMessage = m_loadError; + return false; + } + + QJsonParseError parseErr{}; + const QJsonDocument doc = QJsonDocument::fromJson( stdoutBytes, &parseErr ); + if ( parseErr.error != QJsonParseError::NoError || !doc.isObject() ) + { + m_loadError = QString( "Invalid JSON from introspect: %1" ).arg( parseErr.errorString() ); + if ( errorMessage ) *errorMessage = m_loadError; + return false; + } + + const QJsonObject root = doc.object(); + if ( root.contains( "name" ) ) m_name = root.value( "name" ).toString(); + if ( root.contains( "description" ) ) m_description = root.value( "description" ).toString(); + setUiName( m_name() ); + + std::vector taskInputs; + for ( const QJsonValue& tv : root.value( "tasks" ).toArray() ) + { + const QJsonObject taskObj = tv.toObject(); + auto* input = new RimWorkflowTaskInput; + input->setTaskName( taskObj.value( "name" ).toString() ); + input->buildFromSchema( taskObj.value( "config_fields" ).toArray() ); + taskInputs.push_back( input ); + } + + 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() ); + + return true; +} + +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& cand : candidates ) + { + if ( cand.contains( '/' ) || cand.contains( '\\' ) ) + { + if ( QFileInfo( cand ).isExecutable() ) return cand; + } + else if ( !QStandardPaths::findExecutable( cand ).isEmpty() ) + { + return cand; + } + } + return {}; +} diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.h new file mode 100644 index 00000000000..55d15f78d09 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.h @@ -0,0 +1,55 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "cafFilePath.h" +#include "cafPdmChildArrayField.h" +#include "cafPdmField.h" +#include "cafPdmObject.h" + +class RimWorkflowJob; + +class RimWorkflow : public caf::PdmObject +{ + CAF_PDM_HEADER_INIT; + +public: + RimWorkflow(); + + QString name() const; + void setWorkflowDirectory( const QString& directory ); + QString workflowDirectory() const; + + bool loadFromDirectory( QString* errorMessage = nullptr ); + + std::vector jobs() const; + void addJob( RimWorkflowJob* job ); + + static QString findPythonExecutable(); + +protected: + void appendMenuItems( caf::CmdFeatureMenuBuilder& menuBuilder ) const override; + +private: + caf::PdmField m_name; + caf::PdmField m_description; + caf::PdmField m_workflowDirectory; + caf::PdmField m_loadError; + caf::PdmChildArrayField m_jobs; +}; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowBoolBinding.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowBoolBinding.cpp new file mode 100644 index 00000000000..ecd0a4ec065 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowBoolBinding.cpp @@ -0,0 +1,42 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RimWorkflowBoolBinding.h" + +#include + +CAF_PDM_SOURCE_INIT( RimWorkflowBoolBinding, "WorkflowBoolBinding" ); + +RimWorkflowBoolBinding::RimWorkflowBoolBinding() +{ + CAF_PDM_InitField( &m_value, "Value", false, "Value" ); +} + +void RimWorkflowBoolBinding::applySchema( const QJsonObject& fieldSchema ) +{ + RimWorkflowFieldBinding::applySchema( fieldSchema ); + if ( fieldSchema.contains( "default" ) ) + { + m_value = fieldSchema.value( "default" ).toBool( false ); + } +} + +QString RimWorkflowBoolBinding::toYamlValue() const +{ + return m_value() ? "true" : "false"; +} diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowBoolBinding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowBoolBinding.h new file mode 100644 index 00000000000..7dceffe2daa --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowBoolBinding.h @@ -0,0 +1,37 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "RimWorkflowFieldBinding.h" + +class RimWorkflowBoolBinding : public RimWorkflowFieldBinding +{ + CAF_PDM_HEADER_INIT; + +public: + RimWorkflowBoolBinding(); + + void applySchema( const QJsonObject& fieldSchema ) override; + QString toYamlValue() const override; + + caf::PdmFieldHandle* valueField() override { return &m_value; } + +private: + caf::PdmField m_value; +}; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCaseBinding.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCaseBinding.cpp new file mode 100644 index 00000000000..88ac22376ac --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCaseBinding.cpp @@ -0,0 +1,42 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RimWorkflowCaseBinding.h" + +#include "RimEclipseCase.h" +#include "RimTools.h" + +CAF_PDM_SOURCE_INIT( RimWorkflowCaseBinding, "WorkflowCaseBinding" ); + +RimWorkflowCaseBinding::RimWorkflowCaseBinding() +{ + CAF_PDM_InitFieldNoDefault( &m_case, "Case", "Case" ); +} + +QString RimWorkflowCaseBinding::toYamlValue() const +{ + if ( m_case() == nullptr ) return "null"; + return QString( "{__resinsight_ref__: EclipseCase, case_id: %1}" ).arg( m_case()->caseId() ); +} + +QList RimWorkflowCaseBinding::calculateValueOptions( const caf::PdmFieldHandle* fieldNeedingOptions ) +{ + QList options; + if ( fieldNeedingOptions == &m_case ) RimTools::eclipseCaseOptionItems( &options ); + return options; +} diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCaseBinding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCaseBinding.h new file mode 100644 index 00000000000..18efaa096f8 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCaseBinding.h @@ -0,0 +1,42 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "RimWorkflowFieldBinding.h" + +#include "cafPdmPtrField.h" + +class RimEclipseCase; + +class RimWorkflowCaseBinding : public RimWorkflowFieldBinding +{ + CAF_PDM_HEADER_INIT; + +public: + RimWorkflowCaseBinding(); + + QString toYamlValue() const override; + + caf::PdmFieldHandle* valueField() override { return &m_case; } + +private: + QList calculateValueOptions( const caf::PdmFieldHandle* fieldNeedingOptions ) override; + + caf::PdmPtrField m_case; +}; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCollection.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCollection.cpp new file mode 100644 index 00000000000..3468c16faf3 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCollection.cpp @@ -0,0 +1,58 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RimWorkflowCollection.h" + +#include +#include + +CAF_PDM_SOURCE_INIT( RimWorkflowCollection, "WorkflowCollection" ); + +RimWorkflowCollection::RimWorkflowCollection() +{ + CAF_PDM_InitObject( "Workflows", ":/Folder.png" ); + CAF_PDM_InitFieldNoDefault( &m_items, "Workflows", "" ); + + rescanWorkflows(); +} + +RimWorkflowCollection::~RimWorkflowCollection() = default; + +QString RimWorkflowCollection::discoveryDirectory() +{ + return QDir::homePath() + "/.taskmaestro/workflows"; +} + +void RimWorkflowCollection::rescanWorkflows() +{ + deleteAllItems(); + + QDir dir( discoveryDirectory() ); + if ( !dir.exists() ) return; + + 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 ); + } +} diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCollection.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCollection.h new file mode 100644 index 00000000000..49810d8a331 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCollection.h @@ -0,0 +1,38 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "RimWorkflow.h" + +#include "cafPdmObjectCollection.h" + +#include + +class RimWorkflowCollection : public caf::PdmObjectCollection +{ + CAF_PDM_HEADER_INIT; + +public: + RimWorkflowCollection(); + ~RimWorkflowCollection() override; + + void rescanWorkflows(); + + static QString discoveryDirectory(); +}; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDateBinding.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDateBinding.cpp new file mode 100644 index 00000000000..0985bdf65d2 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDateBinding.cpp @@ -0,0 +1,43 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RimWorkflowDateBinding.h" + +#include + +CAF_PDM_SOURCE_INIT( RimWorkflowDateBinding, "WorkflowDateBinding" ); + +RimWorkflowDateBinding::RimWorkflowDateBinding() +{ + CAF_PDM_InitField( &m_value, "Value", QDate::currentDate(), "Value" ); +} + +void RimWorkflowDateBinding::applySchema( const QJsonObject& fieldSchema ) +{ + RimWorkflowFieldBinding::applySchema( fieldSchema ); + if ( fieldSchema.contains( "default" ) ) + { + QDate parsed = QDate::fromString( fieldSchema.value( "default" ).toString(), Qt::ISODate ); + if ( parsed.isValid() ) m_value = parsed; + } +} + +QString RimWorkflowDateBinding::toYamlValue() const +{ + return QString( "\"%1\"" ).arg( m_value().toString( Qt::ISODate ) ); +} diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDateBinding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDateBinding.h new file mode 100644 index 00000000000..f9e88fd3da3 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDateBinding.h @@ -0,0 +1,39 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "RimWorkflowFieldBinding.h" + +#include + +class RimWorkflowDateBinding : public RimWorkflowFieldBinding +{ + CAF_PDM_HEADER_INIT; + +public: + RimWorkflowDateBinding(); + + void applySchema( const QJsonObject& fieldSchema ) override; + QString toYamlValue() const override; + + caf::PdmFieldHandle* valueField() override { return &m_value; } + +private: + caf::PdmField m_value; +}; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFieldBinding.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFieldBinding.cpp new file mode 100644 index 00000000000..1ab79ac0ff4 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFieldBinding.cpp @@ -0,0 +1,71 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RimWorkflowFieldBinding.h" + +#include + +CAF_PDM_ABSTRACT_SOURCE_INIT( RimWorkflowFieldBinding, "WorkflowFieldBinding" ); + +RimWorkflowFieldBinding::RimWorkflowFieldBinding() +{ + CAF_PDM_InitObject( "Field", ":/Bullet.png" ); + + CAF_PDM_InitFieldNoDefault( &m_fieldName, "FieldName", "Field" ); + m_fieldName.uiCapability()->setUiReadOnly( true ); + + CAF_PDM_InitFieldNoDefault( &m_description, "Description", "Description" ); + m_description.uiCapability()->setUiReadOnly( true ); + + CAF_PDM_InitField( &m_required, "Required", false, "Required" ); + m_required.uiCapability()->setUiReadOnly( true ); +} + +QString RimWorkflowFieldBinding::fieldName() const +{ + return m_fieldName(); +} + +void RimWorkflowFieldBinding::setFieldName( const QString& name ) +{ + m_fieldName = name; + setUiName( name ); +} + +void RimWorkflowFieldBinding::setDescription( const QString& description ) +{ + m_description = description; +} + +void RimWorkflowFieldBinding::setRequired( bool required ) +{ + m_required = required; +} + +void RimWorkflowFieldBinding::applySchema( const QJsonObject& fieldSchema ) +{ + setFieldName( fieldSchema.value( "name" ).toString() ); + setDescription( fieldSchema.value( "description" ).toString() ); + setRequired( fieldSchema.value( "required" ).toBool( false ) ); + + if ( auto* vf = valueField() ) + { + vf->uiCapability()->setUiName( m_fieldName() ); + if ( !m_description().isEmpty() ) vf->uiCapability()->setUiToolTip( m_description() ); + } +} diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFieldBinding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFieldBinding.h new file mode 100644 index 00000000000..04c412bcf01 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFieldBinding.h @@ -0,0 +1,47 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "cafPdmField.h" +#include "cafPdmObject.h" + +class QJsonObject; + +class RimWorkflowFieldBinding : public caf::PdmObject +{ + CAF_PDM_HEADER_INIT; + +public: + RimWorkflowFieldBinding(); + + QString fieldName() const; + void setFieldName( const QString& name ); + void setDescription( const QString& description ); + void setRequired( bool required ); + + virtual void applySchema( const QJsonObject& fieldSchema ); + virtual QString toYamlValue() const = 0; + + virtual caf::PdmFieldHandle* valueField() = 0; + +protected: + caf::PdmField m_fieldName; + caf::PdmField m_description; + caf::PdmField m_required; +}; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFilePathBinding.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFilePathBinding.cpp new file mode 100644 index 00000000000..47dc86b1e20 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFilePathBinding.cpp @@ -0,0 +1,64 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RimWorkflowFilePathBinding.h" + +#include "cafPdmUiFilePathEditor.h" + +#include + +CAF_PDM_SOURCE_INIT( RimWorkflowFilePathBinding, "WorkflowFilePathBinding" ); + +RimWorkflowFilePathBinding::RimWorkflowFilePathBinding() + : m_selectDirectory( false ) +{ + CAF_PDM_InitFieldNoDefault( &m_value, "Value", "Value" ); + m_value.uiCapability()->setUiEditorTypeName( caf::PdmUiFilePathEditor::uiEditorTypeName() ); +} + +void RimWorkflowFilePathBinding::applySchema( const QJsonObject& fieldSchema ) +{ + RimWorkflowFieldBinding::applySchema( fieldSchema ); + + const QString fmt = fieldSchema.value( "format" ).toString(); + m_selectDirectory = ( fmt == "directory-path" ); + + if ( fieldSchema.contains( "default" ) ) + { + m_value = fieldSchema.value( "default" ).toString(); + } +} + +QString RimWorkflowFilePathBinding::toYamlValue() const +{ + QString p = m_value().path(); + p.replace( "\\", "\\\\" ); + p.replace( "\"", "\\\"" ); + return QString( "\"%1\"" ).arg( p ); +} + +void RimWorkflowFilePathBinding::defineEditorAttribute( const caf::PdmFieldHandle* field, QString uiConfigName, caf::PdmUiEditorAttribute* attribute ) +{ + if ( field == &m_value ) + { + if ( auto* attr = dynamic_cast( attribute ) ) + { + attr->m_selectDirectory = m_selectDirectory; + } + } +} diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFilePathBinding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFilePathBinding.h new file mode 100644 index 00000000000..60ae3e8753e --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFilePathBinding.h @@ -0,0 +1,43 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "RimWorkflowFieldBinding.h" + +#include "cafFilePath.h" + +class RimWorkflowFilePathBinding : public RimWorkflowFieldBinding +{ + CAF_PDM_HEADER_INIT; + +public: + RimWorkflowFilePathBinding(); + + void applySchema( const QJsonObject& fieldSchema ) override; + QString toYamlValue() const override; + + caf::PdmFieldHandle* valueField() override { return &m_value; } + +protected: + void defineEditorAttribute( const caf::PdmFieldHandle* field, QString uiConfigName, caf::PdmUiEditorAttribute* attribute ) override; + +private: + caf::PdmField m_value; + bool m_selectDirectory; +}; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFloatBinding.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFloatBinding.cpp new file mode 100644 index 00000000000..7777f949339 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFloatBinding.cpp @@ -0,0 +1,42 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RimWorkflowFloatBinding.h" + +#include + +CAF_PDM_SOURCE_INIT( RimWorkflowFloatBinding, "WorkflowFloatBinding" ); + +RimWorkflowFloatBinding::RimWorkflowFloatBinding() +{ + CAF_PDM_InitField( &m_value, "Value", 0.0, "Value" ); +} + +void RimWorkflowFloatBinding::applySchema( const QJsonObject& fieldSchema ) +{ + RimWorkflowFieldBinding::applySchema( fieldSchema ); + if ( fieldSchema.contains( "default" ) ) + { + m_value = fieldSchema.value( "default" ).toDouble(); + } +} + +QString RimWorkflowFloatBinding::toYamlValue() const +{ + return QString::number( m_value(), 'g', 17 ); +} diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFloatBinding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFloatBinding.h new file mode 100644 index 00000000000..c0607cc7de2 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFloatBinding.h @@ -0,0 +1,37 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "RimWorkflowFieldBinding.h" + +class RimWorkflowFloatBinding : public RimWorkflowFieldBinding +{ + CAF_PDM_HEADER_INIT; + +public: + RimWorkflowFloatBinding(); + + void applySchema( const QJsonObject& fieldSchema ) override; + QString toYamlValue() const override; + + caf::PdmFieldHandle* valueField() override { return &m_value; } + +private: + caf::PdmField m_value; +}; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowIntBinding.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowIntBinding.cpp new file mode 100644 index 00000000000..28992ad1e78 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowIntBinding.cpp @@ -0,0 +1,42 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RimWorkflowIntBinding.h" + +#include + +CAF_PDM_SOURCE_INIT( RimWorkflowIntBinding, "WorkflowIntBinding" ); + +RimWorkflowIntBinding::RimWorkflowIntBinding() +{ + CAF_PDM_InitField( &m_value, "Value", 0, "Value" ); +} + +void RimWorkflowIntBinding::applySchema( const QJsonObject& fieldSchema ) +{ + RimWorkflowFieldBinding::applySchema( fieldSchema ); + if ( fieldSchema.contains( "default" ) ) + { + m_value = fieldSchema.value( "default" ).toInt(); + } +} + +QString RimWorkflowIntBinding::toYamlValue() const +{ + return QString::number( m_value() ); +} diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowIntBinding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowIntBinding.h new file mode 100644 index 00000000000..202316c95ac --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowIntBinding.h @@ -0,0 +1,37 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "RimWorkflowFieldBinding.h" + +class RimWorkflowIntBinding : public RimWorkflowFieldBinding +{ + CAF_PDM_HEADER_INIT; + +public: + RimWorkflowIntBinding(); + + void applySchema( const QJsonObject& fieldSchema ) override; + QString toYamlValue() const override; + + caf::PdmFieldHandle* valueField() override { return &m_value; } + +private: + caf::PdmField m_value; +}; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.cpp new file mode 100644 index 00000000000..afe6e5cafe9 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.cpp @@ -0,0 +1,195 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RimWorkflowJob.h" + +#include "RimWorkflow.h" + +#include "RiaApplication.h" +#include "RiaLogging.h" + +#include "RiuMainWindow.h" +#include "RiuWorkflowJobRunner.h" + +#include "cafCmdFeatureMenuBuilder.h" +#include "cafPdmUiGroup.h" +#include "cafPdmUiOrdering.h" +#include "cafPdmUiTreeOrdering.h" + +#include +#include +#include +#include + +CAF_PDM_SOURCE_INIT( RimWorkflowJob, "WorkflowJob" ); + +RimWorkflowJob::RimWorkflowJob() +{ + CAF_PDM_InitObject( "Job", ":/Bullet.png" ); + + CAF_PDM_InitFieldNoDefault( &m_name, "Name", "Name" ); + + CAF_PDM_InitFieldNoDefault( &m_taskInputs, "TaskInputs", "" ); +} + +void RimWorkflowJob::setJobName( const QString& name ) +{ + m_name = name; + setUiName( name ); +} + +void RimWorkflowJob::initAfterRead() +{ + setUiName( m_name() ); +} + +void RimWorkflowJob::defineUiOrdering( QString uiConfigName, caf::PdmUiOrdering& uiOrdering ) +{ + uiOrdering.add( &m_name ); + + for ( RimWorkflowTaskInput* task : m_taskInputs.childrenByType() ) + { + if ( !task ) continue; + caf::PdmUiGroup* group = uiOrdering.addNewGroup( task->taskName() ); + for ( RimWorkflowFieldBinding* b : task->items() ) + { + if ( b && b->valueField() ) group->add( b->valueField() ); + } + } + uiOrdering.skipRemainingFields( true ); +} + +void RimWorkflowJob::defineUiTreeOrdering( caf::PdmUiTreeOrdering& uiTreeOrdering, QString uiConfigName ) +{ + uiTreeOrdering.skipRemainingChildren( true ); +} + +std::vector RimWorkflowJob::taskInputs() const +{ + std::vector result; + result.reserve( m_taskInputs.size() ); + for ( RimWorkflowTaskInput* t : m_taskInputs.childrenByType() ) + { + if ( t ) result.push_back( t ); + } + return result; +} + +void RimWorkflowJob::setTaskInputs( std::vector inputs ) +{ + m_taskInputs.deleteChildren(); + for ( RimWorkflowTaskInput* t : inputs ) + { + if ( t ) m_taskInputs.push_back( t ); + } +} + +void RimWorkflowJob::fieldChangedByUi( const caf::PdmFieldHandle* changedField, const QVariant& oldValue, const QVariant& newValue ) +{ + if ( changedField == &m_name ) + { + setUiName( m_name() ); + uiCapability()->updateConnectedEditors(); + } +} + +void RimWorkflowJob::appendMenuItems( caf::CmdFeatureMenuBuilder& menuBuilder ) const +{ + menuBuilder << "RicRunWorkflowJobFeature"; + menuBuilder << "RicCancelWorkflowJobFeature"; +} + +bool RimWorkflowJob::isRunning() const +{ + return m_runner && m_runner->isRunning(); +} + +void RimWorkflowJob::cancelJob() +{ + if ( m_runner ) m_runner->cancel(); +} + +QString RimWorkflowJob::writeInputYaml( const QString& path ) const +{ + QString body; + for ( RimWorkflowTaskInput* t : m_taskInputs.childrenByType() ) + { + if ( t ) body += t->toTaskYamlBlock(); + } + + QFile out( path ); + if ( !out.open( QIODevice::WriteOnly | QIODevice::Truncate ) ) return {}; + out.write( body.toUtf8() ); + out.close(); + return path; +} + +void RimWorkflowJob::runJob() +{ + if ( isRunning() ) + { + RiaLogging::warning( "Job is already running." ); + return; + } + + auto* workflow = firstAncestorOrThisOfType(); + if ( !workflow ) + { + RiaLogging::warning( "Cannot run job: parent workflow not found." ); + return; + } + + const QString dir = workflow->workflowDirectory(); + if ( dir.isEmpty() ) return; + + auto port = RiaApplication::instance()->activeGrpcPortNumber(); + if ( !port.has_value() ) + { + RiaLogging::warning( "Cannot run workflow: gRPC server is not active. Enable it in Preferences." ); + return; + } + + QString python = RimWorkflow::findPythonExecutable(); + if ( python.isEmpty() ) + { + RiaLogging::warning( "Cannot run workflow: no Python interpreter found." ); + return; + } + + QDir tmp( QDir::tempPath() ); + QString runDir = QString( "resinsight_workflow_%1" ).arg( QUuid::createUuid().toString( QUuid::WithoutBraces ) ); + if ( !tmp.mkpath( runDir ) ) + { + RiaLogging::warning( "Cannot create temp dir for workflow run." ); + return; + } + QString inputPath = tmp.absoluteFilePath( runDir + "/input.yaml" ); + if ( writeInputYaml( inputPath ).isEmpty() ) + { + RiaLogging::warning( "Failed to write input.yaml" ); + return; + } + + QStringList args{ "-m", "rips.taskmaestro_helper", "run", dir, "--input", inputPath, "--grpc-port", QString::number( port.value() ) }; + + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + const QString label = workflow->uiName() + " / " + m_name(); + + m_runner = new RiuWorkflowJobRunner( label, RiuMainWindow::instance() ); + m_runner->start( python, args, env ); +} diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.h new file mode 100644 index 00000000000..326134aca7c --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.h @@ -0,0 +1,59 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "RimWorkflowTaskInput.h" + +#include "cafPdmChildArrayField.h" +#include "cafPdmField.h" +#include "cafPdmObject.h" + +#include + +class RiuWorkflowJobRunner; + +class RimWorkflowJob : public caf::PdmObject +{ + CAF_PDM_HEADER_INIT; + +public: + RimWorkflowJob(); + + void setJobName( const QString& name ); + + std::vector taskInputs() const; + void setTaskInputs( std::vector inputs ); + + QString writeInputYaml( const QString& path ) const; + void runJob(); + void cancelJob(); + bool isRunning() const; + +protected: + void fieldChangedByUi( const caf::PdmFieldHandle* changedField, const QVariant& oldValue, const QVariant& newValue ) override; + void initAfterRead() override; + void defineUiOrdering( QString uiConfigName, caf::PdmUiOrdering& uiOrdering ) override; + void defineUiTreeOrdering( caf::PdmUiTreeOrdering& uiTreeOrdering, QString uiConfigName = "" ) override; + void appendMenuItems( caf::CmdFeatureMenuBuilder& menuBuilder ) const override; + +private: + caf::PdmField m_name; + caf::PdmChildArrayField m_taskInputs; + QPointer m_runner; +}; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowStringBinding.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowStringBinding.cpp new file mode 100644 index 00000000000..69b8c05dbbb --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowStringBinding.cpp @@ -0,0 +1,45 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RimWorkflowStringBinding.h" + +#include + +CAF_PDM_SOURCE_INIT( RimWorkflowStringBinding, "WorkflowStringBinding" ); + +RimWorkflowStringBinding::RimWorkflowStringBinding() +{ + CAF_PDM_InitFieldNoDefault( &m_value, "Value", "Value" ); +} + +void RimWorkflowStringBinding::applySchema( const QJsonObject& fieldSchema ) +{ + RimWorkflowFieldBinding::applySchema( fieldSchema ); + if ( fieldSchema.contains( "default" ) ) + { + m_value = fieldSchema.value( "default" ).toString(); + } +} + +QString RimWorkflowStringBinding::toYamlValue() const +{ + QString v = m_value(); + v.replace( "\\", "\\\\" ); + v.replace( "\"", "\\\"" ); + return QString( "\"%1\"" ).arg( v ); +} diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowStringBinding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowStringBinding.h new file mode 100644 index 00000000000..aaabd427aae --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowStringBinding.h @@ -0,0 +1,37 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "RimWorkflowFieldBinding.h" + +class RimWorkflowStringBinding : public RimWorkflowFieldBinding +{ + CAF_PDM_HEADER_INIT; + +public: + RimWorkflowStringBinding(); + + void applySchema( const QJsonObject& fieldSchema ) override; + QString toYamlValue() const override; + + caf::PdmFieldHandle* valueField() override { return &m_value; } + +private: + caf::PdmField m_value; +}; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.cpp new file mode 100644 index 00000000000..fe34b256fd6 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.cpp @@ -0,0 +1,119 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RimWorkflowTaskInput.h" + +#include "RimWorkflowBoolBinding.h" +#include "RimWorkflowCaseBinding.h" +#include "RimWorkflowDateBinding.h" +#include "RimWorkflowFilePathBinding.h" +#include "RimWorkflowFloatBinding.h" +#include "RimWorkflowIntBinding.h" +#include "RimWorkflowStringBinding.h" +#include "RimWorkflowViewBinding.h" +#include "RimWorkflowWellPathBinding.h" + +#include "cafPdmUiOrdering.h" +#include "cafPdmUiTreeOrdering.h" + +#include +#include + +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; + + 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; + return new RimWorkflowStringBinding; +} +} // namespace + +RimWorkflowTaskInput::RimWorkflowTaskInput() +{ + CAF_PDM_InitObject( "Task", ":/Bullet.png" ); + CAF_PDM_InitFieldNoDefault( &m_items, "Bindings", "" ); + + CAF_PDM_InitFieldNoDefault( &m_taskName, "TaskName", "Task" ); + m_taskName.uiCapability()->setUiReadOnly( true ); +} + +QString RimWorkflowTaskInput::taskName() const +{ + return m_taskName(); +} + +void RimWorkflowTaskInput::setTaskName( const QString& name ) +{ + m_taskName = name; + setUiName( name ); +} + +void RimWorkflowTaskInput::buildFromSchema( const QJsonArray& configFields ) +{ + deleteAllItems(); + for ( const QJsonValue& v : configFields ) + { + const QJsonObject schema = v.toObject(); + auto* binding = createBinding( schema ); + binding->applySchema( schema ); + addItem( binding ); + } +} + +void RimWorkflowTaskInput::defineUiOrdering( QString uiConfigName, caf::PdmUiOrdering& uiOrdering ) +{ + for ( RimWorkflowFieldBinding* b : items() ) + { + if ( b && b->valueField() ) uiOrdering.add( b->valueField() ); + } + uiOrdering.skipRemainingFields( true ); +} + +void RimWorkflowTaskInput::defineUiTreeOrdering( caf::PdmUiTreeOrdering& uiTreeOrdering, QString uiConfigName ) +{ + 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; +} diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.h new file mode 100644 index 00000000000..8a15fea8a15 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.h @@ -0,0 +1,47 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "RimWorkflowFieldBinding.h" + +#include "cafPdmObjectCollection.h" + +class QJsonArray; + +class RimWorkflowTaskInput : public caf::PdmObjectCollection +{ + CAF_PDM_HEADER_INIT; + +public: + RimWorkflowTaskInput(); + + QString taskName() const; + void setTaskName( const QString& name ); + + void buildFromSchema( const QJsonArray& configFields ); + + QString toTaskYamlBlock() const; + +protected: + void defineUiOrdering( QString uiConfigName, caf::PdmUiOrdering& uiOrdering ) override; + void defineUiTreeOrdering( caf::PdmUiTreeOrdering& uiTreeOrdering, QString uiConfigName = "" ) override; + +private: + caf::PdmField m_taskName; +}; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowViewBinding.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowViewBinding.cpp new file mode 100644 index 00000000000..7d04233b574 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowViewBinding.cpp @@ -0,0 +1,58 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RimWorkflowViewBinding.h" + +#include "RimEclipseCase.h" +#include "RimEclipseCaseTools.h" +#include "RimEclipseView.h" +#include "RimProject.h" + +CAF_PDM_SOURCE_INIT( RimWorkflowViewBinding, "WorkflowViewBinding" ); + +RimWorkflowViewBinding::RimWorkflowViewBinding() +{ + CAF_PDM_InitFieldNoDefault( &m_view, "View", "View" ); +} + +QString RimWorkflowViewBinding::toYamlValue() const +{ + if ( m_view() == nullptr ) return "null"; + return QString( "{__resinsight_ref__: View, view_id: %1}" ).arg( m_view()->id() ); +} + +QList RimWorkflowViewBinding::calculateValueOptions( const caf::PdmFieldHandle* fieldNeedingOptions ) +{ + QList options; + if ( fieldNeedingOptions != &m_view ) return options; + + auto* project = RimProject::current(); + if ( project == nullptr ) return options; + + for ( RimEclipseCase* eclipseCase : RimEclipseCaseTools::eclipseCases() ) + { + if ( eclipseCase == nullptr ) continue; + for ( RimEclipseView* view : eclipseCase->reservoirViews() ) + { + if ( view == nullptr ) continue; + QString label = QString( "%1 / %2" ).arg( eclipseCase->caseUserDescription(), view->name() ); + options.push_back( caf::PdmOptionItemInfo( label, view ) ); + } + } + return options; +} diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowViewBinding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowViewBinding.h new file mode 100644 index 00000000000..8c684dc56b8 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowViewBinding.h @@ -0,0 +1,42 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "RimWorkflowFieldBinding.h" + +#include "cafPdmPtrField.h" + +class RimEclipseView; + +class RimWorkflowViewBinding : public RimWorkflowFieldBinding +{ + CAF_PDM_HEADER_INIT; + +public: + RimWorkflowViewBinding(); + + QString toYamlValue() const override; + + caf::PdmFieldHandle* valueField() override { return &m_view; } + +private: + QList calculateValueOptions( const caf::PdmFieldHandle* fieldNeedingOptions ) override; + + caf::PdmPtrField m_view; +}; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowWellPathBinding.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowWellPathBinding.cpp new file mode 100644 index 00000000000..2be242e0240 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowWellPathBinding.cpp @@ -0,0 +1,44 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RimWorkflowWellPathBinding.h" + +#include "RimTools.h" +#include "RimWellPath.h" + +CAF_PDM_SOURCE_INIT( RimWorkflowWellPathBinding, "WorkflowWellPathBinding" ); + +RimWorkflowWellPathBinding::RimWorkflowWellPathBinding() +{ + CAF_PDM_InitFieldNoDefault( &m_wellPath, "WellPath", "Well Path" ); +} + +QString RimWorkflowWellPathBinding::toYamlValue() const +{ + if ( m_wellPath() == nullptr ) return "null"; + QString name = m_wellPath()->name(); + name.replace( "\"", "\\\"" ); + return QString( "{__resinsight_ref__: WellPath, well_path_name: \"%1\"}" ).arg( name ); +} + +QList RimWorkflowWellPathBinding::calculateValueOptions( const caf::PdmFieldHandle* fieldNeedingOptions ) +{ + QList options; + if ( fieldNeedingOptions == &m_wellPath ) RimTools::wellPathOptionItems( &options ); + return options; +} diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowWellPathBinding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowWellPathBinding.h new file mode 100644 index 00000000000..3f030eabd81 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowWellPathBinding.h @@ -0,0 +1,42 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "RimWorkflowFieldBinding.h" + +#include "cafPdmPtrField.h" + +class RimWellPath; + +class RimWorkflowWellPathBinding : public RimWorkflowFieldBinding +{ + CAF_PDM_HEADER_INIT; + +public: + RimWorkflowWellPathBinding(); + + QString toYamlValue() const override; + + caf::PdmFieldHandle* valueField() override { return &m_wellPath; } + +private: + QList calculateValueOptions( const caf::PdmFieldHandle* fieldNeedingOptions ) override; + + caf::PdmPtrField m_wellPath; +}; diff --git a/ApplicationLibCode/UserInterface/CMakeLists_files.cmake b/ApplicationLibCode/UserInterface/CMakeLists_files.cmake index 38359278644..ce96ad4d872 100644 --- a/ApplicationLibCode/UserInterface/CMakeLists_files.cmake +++ b/ApplicationLibCode/UserInterface/CMakeLists_files.cmake @@ -76,6 +76,7 @@ set(SOURCE_GROUP_HEADER_FILES ${CMAKE_CURRENT_LIST_DIR}/RiuNightchartsWidget.h ${CMAKE_CURRENT_LIST_DIR}/RiuMessagePanel.h ${CMAKE_CURRENT_LIST_DIR}/RiuMessageDialog.h + ${CMAKE_CURRENT_LIST_DIR}/RiuWorkflowJobRunner.h ${CMAKE_CURRENT_LIST_DIR}/RiuPlotObjectPicker.h ${CMAKE_CURRENT_LIST_DIR}/RiuContextMenuLauncher.h ${CMAKE_CURRENT_LIST_DIR}/RiuSummaryCurveDefinitionKeywords.h @@ -193,6 +194,7 @@ set(SOURCE_GROUP_SOURCE_FILES ${CMAKE_CURRENT_LIST_DIR}/RiuNightchartsWidget.cpp ${CMAKE_CURRENT_LIST_DIR}/RiuMessagePanel.cpp ${CMAKE_CURRENT_LIST_DIR}/RiuMessageDialog.cpp + ${CMAKE_CURRENT_LIST_DIR}/RiuWorkflowJobRunner.cpp ${CMAKE_CURRENT_LIST_DIR}/RiuPlotObjectPicker.cpp ${CMAKE_CURRENT_LIST_DIR}/RiuContextMenuLauncher.cpp ${CMAKE_CURRENT_LIST_DIR}/RiuSummaryVectorSelectionUi.cpp diff --git a/ApplicationLibCode/UserInterface/RiuWorkflowJobRunner.cpp b/ApplicationLibCode/UserInterface/RiuWorkflowJobRunner.cpp new file mode 100644 index 00000000000..b39cddfc92d --- /dev/null +++ b/ApplicationLibCode/UserInterface/RiuWorkflowJobRunner.cpp @@ -0,0 +1,104 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RiuWorkflowJobRunner.h" + +RiuWorkflowJobRunner::RiuWorkflowJobRunner( const QString& label, QObject* parent ) + : QObject( parent ) + , m_label( label ) +{ + connect( &m_process, &QProcess::readyReadStandardOutput, this, &RiuWorkflowJobRunner::onReadyReadStdout ); + connect( &m_process, &QProcess::readyReadStandardError, this, &RiuWorkflowJobRunner::onReadyReadStderr ); + connect( &m_process, QOverload::of( &QProcess::finished ), this, &RiuWorkflowJobRunner::onProcessFinished ); +} + +void RiuWorkflowJobRunner::start( const QString& program, const QStringList& arguments, const QProcessEnvironment& env ) +{ + RiaLogging::info( QString( "Running %1" ).arg( m_label ).toStdString() ); + RiaLogging::info( QString( "[%1] $ %2 %3" ).arg( m_label, program, arguments.join( ' ' ) ).toStdString() ); + m_process.setProcessEnvironment( env ); + m_process.start( program, arguments ); +} + +void RiuWorkflowJobRunner::cancel() +{ + if ( m_process.state() == QProcess::NotRunning ) return; + m_process.terminate(); + if ( !m_process.waitForFinished( 2000 ) ) m_process.kill(); +} + +bool RiuWorkflowJobRunner::isRunning() const +{ + return m_process.state() != QProcess::NotRunning; +} + +void RiuWorkflowJobRunner::onReadyReadStdout() +{ + m_stdoutBuf.append( m_process.readAllStandardOutput() ); + drainLines( m_stdoutBuf, RILogLevel::RI_LL_INFO ); +} + +void RiuWorkflowJobRunner::onReadyReadStderr() +{ + m_stderrBuf.append( m_process.readAllStandardError() ); + drainLines( m_stderrBuf, RILogLevel::RI_LL_WARNING ); +} + +void RiuWorkflowJobRunner::onProcessFinished( int exitCode, QProcess::ExitStatus status ) +{ + m_stdoutBuf.append( m_process.readAllStandardOutput() ); + m_stderrBuf.append( m_process.readAllStandardError() ); + + if ( !m_stdoutBuf.endsWith( '\n' ) ) m_stdoutBuf.append( '\n' ); + if ( !m_stderrBuf.endsWith( '\n' ) ) m_stderrBuf.append( '\n' ); + drainLines( m_stdoutBuf, RILogLevel::RI_LL_INFO ); + drainLines( m_stderrBuf, RILogLevel::RI_LL_WARNING ); + + if ( status == QProcess::NormalExit && exitCode == 0 ) + { + RiaLogging::info( QString( "Finished %1 (exit 0)" ).arg( m_label ).toStdString() ); + } + else if ( status == QProcess::NormalExit ) + { + RiaLogging::error( QString( "Finished %1 (exit %2)" ).arg( m_label ).arg( exitCode ).toStdString() ); + } + else + { + RiaLogging::error( QString( "%1 crashed" ).arg( m_label ).toStdString() ); + } + + deleteLater(); +} + +void RiuWorkflowJobRunner::drainLines( QByteArray& buffer, RILogLevel level ) +{ + int nl; + while ( ( nl = buffer.indexOf( '\n' ) ) >= 0 ) + { + QString line = QString::fromUtf8( buffer.left( nl ) ); + buffer.remove( 0, nl + 1 ); + if ( line.endsWith( '\r' ) ) line.chop( 1 ); + if ( line.isEmpty() ) continue; + + const std::string msg = QString( "[%1] %2" ).arg( m_label, line ).toStdString(); + if ( level == RILogLevel::RI_LL_WARNING ) + RiaLogging::warning( msg ); + else + RiaLogging::info( msg ); + } +} diff --git a/ApplicationLibCode/UserInterface/RiuWorkflowJobRunner.h b/ApplicationLibCode/UserInterface/RiuWorkflowJobRunner.h new file mode 100644 index 00000000000..7b16c240d16 --- /dev/null +++ b/ApplicationLibCode/UserInterface/RiuWorkflowJobRunner.h @@ -0,0 +1,51 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "RiaLogging.h" + +#include +#include +#include +#include +#include + +class RiuWorkflowJobRunner : public QObject +{ + Q_OBJECT +public: + RiuWorkflowJobRunner( const QString& label, QObject* parent = nullptr ); + + void start( const QString& program, const QStringList& arguments, const QProcessEnvironment& env ); + void cancel(); + bool isRunning() const; + +private slots: + void onReadyReadStdout(); + void onReadyReadStderr(); + void onProcessFinished( int exitCode, QProcess::ExitStatus status ); + +private: + void drainLines( QByteArray& buffer, RILogLevel level ); + + QString m_label; + QProcess m_process; + QByteArray m_stdoutBuf; + QByteArray m_stderrBuf; +}; diff --git a/GrpcInterface/Python/rips/taskmaestro_helper/__init__.py b/GrpcInterface/Python/rips/taskmaestro_helper/__init__.py new file mode 100644 index 00000000000..a818a29ea44 --- /dev/null +++ b/GrpcInterface/Python/rips/taskmaestro_helper/__init__.py @@ -0,0 +1,6 @@ +"""Bridge between ResInsight and the taskmaestro workflow library. + +ResInsight invokes this package as a subprocess to introspect workflow +schemas and to execute workflows. The Python side exists so ResInsight +itself does not need to parse YAML or know about Pydantic. +""" diff --git a/GrpcInterface/Python/rips/taskmaestro_helper/__main__.py b/GrpcInterface/Python/rips/taskmaestro_helper/__main__.py new file mode 100644 index 00000000000..cbf574f2577 --- /dev/null +++ b/GrpcInterface/Python/rips/taskmaestro_helper/__main__.py @@ -0,0 +1,34 @@ +"""Command-line entry point: `python -m rips.taskmaestro_helper `.""" + +from __future__ import annotations + +import sys + + +def main() -> int: + if len(sys.argv) < 2: + print( + "usage: python -m rips.taskmaestro_helper ...", + file=sys.stderr, + ) + return 2 + + subcommand = sys.argv[1] + rest = sys.argv[2:] + + if subcommand == "introspect": + from . import introspect + + return introspect.main(rest) + + if subcommand == "run": + from . import run + + return run.main(rest) + + print(f"unknown subcommand: {subcommand}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/GrpcInterface/Python/rips/taskmaestro_helper/introspect.py b/GrpcInterface/Python/rips/taskmaestro_helper/introspect.py new file mode 100644 index 00000000000..00a9a842ff5 --- /dev/null +++ b/GrpcInterface/Python/rips/taskmaestro_helper/introspect.py @@ -0,0 +1,186 @@ +"""Introspect a taskmaestro workflow directory and emit a JSON schema. + +The schema is consumed by ResInsight to auto-generate property-editor +fields for each task's config inputs. Only fields listed in the workflow's +`config_fields` are inspected, so models that also reference non-Pydantic +types (e.g. live `rips` objects passed via `depends_on`) do not break +introspection. +""" + +from __future__ import annotations + +import datetime +import json +import pathlib +import sys +from pathlib import Path +from types import UnionType +from typing import Annotated, Any, Union, get_args, get_origin + +from pydantic import TypeAdapter +from pydantic.fields import FieldInfo + +from .refs import annotation_to_resinsight_type + + +_SCALAR_TYPE_MAP: dict[type, str] = { + str: "string", + bool: "boolean", + int: "integer", + float: "number", +} + + +def _format_for(field_info: FieldInfo) -> str | None: + """Ask Pydantic for the JSON-schema `format` of a field, if any. + + Pydantic stores e.g. `pydantic.DirectoryPath` (which is + `Annotated[Path, PathType('dir')]`) as `annotation=Path` and + `metadata=[PathType('dir')]`. We reassemble both before asking + TypeAdapter, so format markers like `directory-path` survive. + """ + annotation = field_info.annotation + if field_info.metadata: + annotation = Annotated[tuple([annotation, *field_info.metadata])] + try: + return TypeAdapter(annotation).json_schema().get("format") + except Exception: + return None + + +def _annotation_type(annotation: object) -> str: + """Map a Pydantic field annotation to a JSON-schema-style type label.""" + if isinstance(annotation, type) and annotation in _SCALAR_TYPE_MAP: + return _SCALAR_TYPE_MAP[annotation] + + # Optional[T] / T | None — peel the union and map the non-None arm + origin = get_origin(annotation) + if origin in (Union, UnionType): + non_none = [a for a in get_args(annotation) if a is not type(None)] + if len(non_none) == 1: + return _annotation_type(non_none[0]) + + if origin in (list, tuple, set, frozenset): + return "array" + + if isinstance(annotation, type): + # Recognised rips object types are reported as "object" with a separate marker. + if annotation_to_resinsight_type(annotation) is not None: + return "object" + + return "string" + + +_SENTINEL = object() + + +def _serialize_default(value: Any) -> Any: + """Convert a Python value into a JSON-friendly form for the schema's `default`.""" + if isinstance(value, (datetime.date, datetime.datetime)): + return value.isoformat() + if isinstance(value, pathlib.PurePath): + return str(value) + return value + + +def _field_schema( + field_name: str, field_info: FieldInfo, input_value: Any = _SENTINEL +) -> dict[str, Any]: + entry: dict[str, Any] = { + "name": field_name, + "type": _annotation_type(field_info.annotation), + "required": field_info.is_required(), + } + if field_info.description: + entry["description"] = field_info.description + + # Prefer the value the user already has in input.yaml; fall back to the + # Pydantic-side default so the UI is pre-filled in either case. + if input_value is not _SENTINEL and input_value is not None: + entry["default"] = _serialize_default(input_value) + elif not field_info.is_required(): + default = field_info.get_default(call_default_factory=False) + if default is not None: + entry["default"] = _serialize_default(default) + + fmt = _format_for(field_info) + if fmt is not None: + entry["format"] = fmt + ri_type = annotation_to_resinsight_type(field_info.annotation) + if ri_type is not None: + entry["resinsight_type"] = ri_type + return entry + + +def collect_schema(workflow_dir: Path) -> dict[str, Any]: + """Load the workflow at `workflow_dir` and produce its UI schema.""" + workflow_yaml = workflow_dir / "workflow.yaml" + input_yaml = workflow_dir / "input.yaml" + if not workflow_yaml.is_file(): + raise FileNotFoundError(f"Missing workflow.yaml in {workflow_dir}") + + # Workflow source files (e.g. pipeline.py) live next to workflow.yaml and + # are referenced as plain dotted paths in workflow.yaml — make them importable. + workflow_dir_str = str(workflow_dir) + if workflow_dir_str not in sys.path: + sys.path.insert(0, workflow_dir_str) + + from taskmaestro.task import get_input_type + from taskmaestro.yaml_config import _load_workflow_only + + wf, jc = _load_workflow_only( + workflow_yaml, + input_yaml if input_yaml.is_file() else None, + ) + + tasks: list[dict[str, Any]] = [] + for task_name, task_cls in wf.topological_order(): + config_field_names = wf.get_config_fields(task_name) + if not config_field_names: + continue + + task_config = jc.get_config_for_task(task_name) if jc is not None else {} + + input_type = get_input_type(task_cls) + config_fields: list[dict[str, Any]] = [] + for field_name in sorted(config_field_names): + field_info = input_type.model_fields.get(field_name) + if field_info is None: + config_fields.append( + { + "name": field_name, + "type": "string", + "required": True, + "error": "field not found in input model", + } + ) + continue + input_value = task_config.get(field_name, _SENTINEL) + config_fields.append( + _field_schema(field_name, field_info, input_value=input_value) + ) + + tasks.append({"name": task_name, "config_fields": config_fields}) + + return { + "name": wf.name, + "description": "", + "tasks": tasks, + } + + +def main(argv: list[str]) -> int: + if len(argv) != 1: + print("usage: introspect ", file=sys.stderr) + return 2 + workflow_dir = Path(argv[0]).resolve() + try: + schema = collect_schema(workflow_dir) + except Exception as exc: + print( + json.dumps({"error": str(exc), "type": type(exc).__name__}), file=sys.stderr + ) + return 1 + json.dump(schema, sys.stdout, indent=2) + sys.stdout.write("\n") + return 0 diff --git a/GrpcInterface/Python/rips/taskmaestro_helper/refs.py b/GrpcInterface/Python/rips/taskmaestro_helper/refs.py new file mode 100644 index 00000000000..de5b1d3c091 --- /dev/null +++ b/GrpcInterface/Python/rips/taskmaestro_helper/refs.py @@ -0,0 +1,56 @@ +"""Mapping between Pydantic field annotations and ResInsight object types. + +The whitelist below is the single source of truth for which `rips` types +the helper recognises as object pickers. It is referenced both by +`introspect` (to add the `resinsight_type` marker on field schemas) and +by `run` (to resolve `__resinsight_ref__` values back to live `rips` +objects). +""" + +from __future__ import annotations + +# Maps rips class name -> resinsight_type label sent to the C++ side. +# The label is also the C++ binding subclass selector (RimWorkflow{Label}Binding). +# Rips classes live in `rips.generated.generated_classes` but are re-exported as +# `rips.`; we match by class name only since the module is generated. +RIPS_TYPE_WHITELIST: dict[str, str] = { + "Case": "EclipseCase", + "EclipseCase": "EclipseCase", + "WellPath": "WellPath", + "View": "View", + "EclipseView": "View", +} + +_RIPS_MODULE_PREFIXES = ("rips.", "rips") + +# Marker used inside input.yaml to denote a ResInsight object reference. +REF_MARKER = "__resinsight_ref__" + + +def annotation_to_resinsight_type(annotation: object) -> str | None: + """Return the resinsight_type label for a Pydantic field annotation, or None. + + Handles both bare rips classes and ObjectModel[rips.X] wrappers used by + the example workflow (e.g. `class GridCase(ObjectModel[rips.EclipseCase])`). + """ + candidates: list[type] = [] + if isinstance(annotation, type): + candidates.append(annotation) + # Walk the MRO for ObjectModel[T] parameterizations: pydantic stores the + # type argument on the intermediate class in __pydantic_generic_metadata__. + for cls in annotation.__mro__: + md = getattr(cls, "__pydantic_generic_metadata__", None) + if not md: + continue + for arg in md.get("args", ()) or (): + if isinstance(arg, type): + candidates.append(arg) + + for cls in candidates: + module = getattr(cls, "__module__", "") or "" + if not module.startswith(_RIPS_MODULE_PREFIXES): + continue + label = RIPS_TYPE_WHITELIST.get(cls.__name__) + if label is not None: + return label + return None diff --git a/GrpcInterface/Python/rips/taskmaestro_helper/run.py b/GrpcInterface/Python/rips/taskmaestro_helper/run.py new file mode 100644 index 00000000000..9c4e7e65f07 --- /dev/null +++ b/GrpcInterface/Python/rips/taskmaestro_helper/run.py @@ -0,0 +1,126 @@ +"""Execute a taskmaestro workflow against a running ResInsight. + +The helper: +1. Connects back to ResInsight via the gRPC port passed by the caller. +2. Reads input.yaml and walks the dict, replacing every + `{__resinsight_ref__: , ...id...}` map with an `ObjectModel`-style + `{"value": }` so taskmaestro's Pydantic validation + accepts it as a wrapped object. +3. Builds the workflow via taskmaestro's loader and runs it. +4. Streams progress events as newline-delimited JSON to stdout for the + ResInsight log dialog to render. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any, Callable + +import yaml + +from .refs import REF_MARKER + + +def _emit(event: str, **fields: Any) -> None: + sys.stdout.write(json.dumps({"event": event, **fields}) + "\n") + sys.stdout.flush() + + +def resolve_refs(data: Any, resolver: Callable[[str, dict[str, Any]], Any]) -> Any: + """Recursively replace `{__resinsight_ref__: ..., ...}` maps in `data`. + + The replacement is a `{"value": }` dict, so Pydantic's + ObjectModel validator can accept it directly. + """ + if isinstance(data, dict): + if REF_MARKER in data: + ri_type = data[REF_MARKER] + extras = {k: v for k, v in data.items() if k != REF_MARKER} + return {"value": resolver(ri_type, extras)} + return {k: resolve_refs(v, resolver) for k, v in data.items()} + if isinstance(data, list): + return [resolve_refs(v, resolver) for v in data] + return data + + +def make_rips_resolver(rips_instance: Any) -> Callable[[str, dict[str, Any]], Any]: + """Resolve a `__resinsight_ref__` map to a live `rips` object.""" + + def resolve(ri_type: str, extras: dict[str, Any]) -> Any: + project = rips_instance.project + if ri_type == "EclipseCase": + return project.case(case_id=extras["case_id"]) + if ri_type == "WellPath": + return project.well_path_by_name(extras["well_path_name"]) + if ri_type == "View": + for v in project.views(): + if getattr(v, "id", None) == extras["view_id"]: + return v + raise LookupError(f"View id {extras['view_id']} not found") + raise LookupError(f"Unknown resinsight_type: {ri_type}") + + return resolve + + +def run_workflow(workflow_dir: Path, input_path: Path, grpc_port: int) -> int: + workflow_dir_str = str(workflow_dir) + if workflow_dir_str not in sys.path: + sys.path.insert(0, workflow_dir_str) + + import rips + + instance = rips.Instance.find(start_port=grpc_port, end_port=grpc_port + 1) + if instance is None: + _emit("error", message=f"Could not connect to ResInsight on port {grpc_port}") + return 1 + _emit("connected", port=grpc_port) + + raw_input = yaml.safe_load(input_path.read_text()) or {} + if not isinstance(raw_input, dict): + _emit("error", message="input.yaml must contain a mapping") + return 1 + + resolver = make_rips_resolver(instance) + try: + resolved = resolve_refs(raw_input, resolver) + except Exception as exc: + _emit("error", message=f"Failed to resolve references: {exc}") + return 1 + + from taskmaestro import EmptyConfig, ExecutionContext, Job, JobConfiguration, Runner + from taskmaestro.yaml_config import _load_workflow_only + + workflow_yaml = workflow_dir / "workflow.yaml" + workflow, _ = _load_workflow_only(workflow_yaml) + + job_config = JobConfiguration(resolved) + job: Job[Any] = Job( + workflow=workflow, config=EmptyConfig(), job_configuration=job_config + ) + + _emit("starting", workflow=workflow.name) + try: + result = Runner().run(job, ctx=ExecutionContext()) + except Exception as exc: + _emit("error", message=str(exc)) + return 1 + + _emit("finished", status=str(result.status), error=str(result.error or "")) + return 0 if result.error is None else 1 + + +def main(argv: list[str]) -> int: + import argparse + + parser = argparse.ArgumentParser( + prog="run", description="Run a taskmaestro workflow." + ) + parser.add_argument("workflow_dir", type=Path) + parser.add_argument("--input", type=Path, required=True) + parser.add_argument("--grpc-port", type=int, required=True) + args = parser.parse_args(argv) + return run_workflow( + args.workflow_dir.resolve(), args.input.resolve(), args.grpc_port + ) diff --git a/GrpcInterface/Python/rips/tests/test_taskmaestro_helper.py b/GrpcInterface/Python/rips/tests/test_taskmaestro_helper.py new file mode 100644 index 00000000000..bbf94a2e585 --- /dev/null +++ b/GrpcInterface/Python/rips/tests/test_taskmaestro_helper.py @@ -0,0 +1,186 @@ +"""Unit tests for rips.taskmaestro_helper. + +These tests do not require a running ResInsight; they exercise the +Pydantic introspection logic in isolation against a synthetic workflow +written into a tmp_path so they stay self-contained. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +pytest.importorskip("taskmaestro") + + +SYNTHETIC_PIPELINE = ''' +"""Synthetic workflow for taskmaestro_helper tests.""" + +from __future__ import annotations + +import datetime +import pathlib + +import pydantic +from pydantic import BaseModel, Field + +from taskmaestro import EmptyConfig, ExecutionContext, Task + + +class GreetInput(BaseModel): + name: str = Field(description="Person to greet", default="world") + times: int = Field(description="How many times", ge=1) + when: datetime.date = Field(description="Greet date", default=datetime.date(2024, 1, 1)) + out_file: pathlib.Path = Field(description="Where to write greeting", default=pathlib.Path("/tmp/hi.txt")) + out_dir: pydantic.DirectoryPath = Field(description="Where to write logs") + + +class GreetOutput(BaseModel): + message: str + + +class Greet(Task[GreetInput, GreetOutput]): + name = "greet" + + def run(self, input: GreetInput, ctx: ExecutionContext) -> GreetOutput: # pragma: no cover + return GreetOutput(message=f"hi {input.name}" * input.times) + + +class StartInput(BaseModel): + pass + + +class StartOutput(BaseModel): + pass + + +class Start(Task[StartInput, StartOutput]): + name = "start" + + def run(self, input: StartInput, ctx: ExecutionContext) -> StartOutput: # pragma: no cover + return StartOutput() +''' + + +SYNTHETIC_WORKFLOW_YAML = """workflow: + name: synthetic + tasks: + - task: pipeline.Start + - task: pipeline.Greet + depends_on: pipeline.Start + config_fields: [name, times, when, out_file, out_dir] +""" + + +SYNTHETIC_INPUT_YAML = """greet: + name: alice + times: 3 + out_dir: /tmp +""" + + +@pytest.fixture +def workflow_dir(tmp_path: Path) -> Path: + (tmp_path / "pipeline.py").write_text(SYNTHETIC_PIPELINE) + (tmp_path / "workflow.yaml").write_text(SYNTHETIC_WORKFLOW_YAML) + (tmp_path / "input.yaml").write_text(SYNTHETIC_INPUT_YAML) + return tmp_path + + +def test_collect_schema_returns_workflow_name_and_tasks(workflow_dir: Path) -> None: + from rips.taskmaestro_helper.introspect import collect_schema + + schema = collect_schema(workflow_dir) + assert schema["name"] == "synthetic" + task_names = [t["name"] for t in schema["tasks"]] + assert task_names == ["greet"] # Start has no config_fields, so omitted + + +def test_collect_schema_extracts_field_types_and_metadata(workflow_dir: Path) -> None: + from rips.taskmaestro_helper.introspect import collect_schema + + schema = collect_schema(workflow_dir) + [greet] = schema["tasks"] + fields_by_name = {f["name"]: f for f in greet["config_fields"]} + + # input.yaml has `name: alice`, so that wins over the Pydantic default "world" + assert fields_by_name["name"]["type"] == "string" + assert fields_by_name["name"]["default"] == "alice" + assert fields_by_name["name"]["description"] == "Person to greet" + assert fields_by_name["name"]["required"] is False + + # input.yaml has `times: 3`; field has no Pydantic default but is required + assert fields_by_name["times"]["type"] == "integer" + assert fields_by_name["times"]["required"] is True + assert fields_by_name["times"]["default"] == 3 + assert "resinsight_type" not in fields_by_name["times"] + + # input.yaml has no `when:` block, so Pydantic default kicks in + assert fields_by_name["when"]["type"] == "string" + assert fields_by_name["when"]["format"] == "date" + assert fields_by_name["when"]["default"] == "2024-01-01" + + # input.yaml has no out_file; Pydantic default flows through + assert fields_by_name["out_file"]["type"] == "string" + assert fields_by_name["out_file"]["format"] == "path" + assert fields_by_name["out_file"]["default"] == "/tmp/hi.txt" + + # input.yaml has `out_dir: /tmp`; pre-fills even though field is required + assert fields_by_name["out_dir"]["type"] == "string" + assert fields_by_name["out_dir"]["format"] == "directory-path" + assert fields_by_name["out_dir"]["required"] is True + assert fields_by_name["out_dir"]["default"] == "/tmp" + + +def test_resolve_refs_substitutes_object_model_value() -> None: + from rips.taskmaestro_helper.run import resolve_refs + + sentinel_case = object() + sentinel_well = object() + + def resolver(ri_type: str, extras: dict) -> object: + if ri_type == "EclipseCase" and extras == {"case_id": 0}: + return sentinel_case + if ri_type == "WellPath" and extras == {"well_path_name": "B-2H"}: + return sentinel_well + raise AssertionError(f"unexpected ref: {ri_type} {extras}") + + raw = { + "load_model": { + "case": {"__resinsight_ref__": "EclipseCase", "case_id": 0}, + "label": "x", + }, + "load_well": { + "well": {"__resinsight_ref__": "WellPath", "well_path_name": "B-2H"}, + }, + "untouched": "stays", + } + out = resolve_refs(raw, resolver) + assert out["load_model"]["case"] == {"value": sentinel_case} + assert out["load_model"]["label"] == "x" + assert out["load_well"]["well"] == {"value": sentinel_well} + assert out["untouched"] == "stays" + + +def test_main_emits_json_to_stdout(workflow_dir: Path) -> None: + rips_root = Path(__file__).resolve().parents[2] + proc = subprocess.run( + [ + sys.executable, + "-m", + "rips.taskmaestro_helper", + "introspect", + str(workflow_dir), + ], + cwd=rips_root, + capture_output=True, + text=True, + check=True, + ) + payload = json.loads(proc.stdout) + assert payload["name"] == "synthetic" + assert {t["name"] for t in payload["tasks"]} == {"greet"}