From 034e238689a12873bf2419e9726bd11aa2b50627 Mon Sep 17 00:00:00 2001 From: Kristian Bendiksen Date: Fri, 15 May 2026 10:49:50 +0200 Subject: [PATCH 1/9] Workflows: Add UI for taskmaestro workflows Discover taskmaestro workflows under ~/.taskmaestro/workflows/ and surface them as a "Workflows" collection in the project tree alongside Scripts. Each workflow's task input schema is auto-generated by introspecting the Pydantic models via a Python helper (rips.taskmaestro_helper) and rendered as PDM field bindings - scalars for str/int/float/bool, and PdmPtrField pickers for fields typed as rips.Case / rips.WellPath / rips.View. The Run button on a RimWorkflow writes input.yaml from current bindings (rips object references serialized as __resinsight_ref__ maps), spawns the helper's run subcommand, and streams output to a modeless log dialog. The helper resolves __resinsight_ref__ markers back to live rips objects via the running ResInsight's gRPC server before driving taskmaestro. --- ApplicationLibCode/CMakeLists.txt | 1 + .../ProjectDataModel/RimProject.cpp | 8 + .../ProjectDataModel/RimProject.h | 2 + .../Workflow/CMakeLists_files.cmake | 35 +++ .../ProjectDataModel/Workflow/RimWorkflow.cpp | 261 ++++++++++++++++++ .../ProjectDataModel/Workflow/RimWorkflow.h | 55 ++++ .../Workflow/RimWorkflowBoolBinding.cpp | 42 +++ .../Workflow/RimWorkflowBoolBinding.h | 35 +++ .../Workflow/RimWorkflowCaseBinding.cpp | 42 +++ .../Workflow/RimWorkflowCaseBinding.h | 40 +++ .../Workflow/RimWorkflowCollection.cpp | 58 ++++ .../Workflow/RimWorkflowCollection.h | 38 +++ .../Workflow/RimWorkflowFieldBinding.cpp | 65 +++++ .../Workflow/RimWorkflowFieldBinding.h | 45 +++ .../Workflow/RimWorkflowNumberBinding.cpp | 50 ++++ .../Workflow/RimWorkflowNumberBinding.h | 37 +++ .../Workflow/RimWorkflowStringBinding.cpp | 45 +++ .../Workflow/RimWorkflowStringBinding.h | 35 +++ .../Workflow/RimWorkflowTaskInput.cpp | 94 +++++++ .../Workflow/RimWorkflowTaskInput.h | 43 +++ .../Workflow/RimWorkflowViewBinding.cpp | 58 ++++ .../Workflow/RimWorkflowViewBinding.h | 40 +++ .../Workflow/RimWorkflowWellPathBinding.cpp | 44 +++ .../Workflow/RimWorkflowWellPathBinding.h | 40 +++ .../UserInterface/CMakeLists_files.cmake | 2 + .../UserInterface/RiuWorkflowRunDialog.cpp | 108 ++++++++ .../UserInterface/RiuWorkflowRunDialog.h | 49 ++++ .../rips/taskmaestro_helper/__init__.py | 6 + .../rips/taskmaestro_helper/__main__.py | 34 +++ .../rips/taskmaestro_helper/introspect.py | 138 +++++++++ .../Python/rips/taskmaestro_helper/refs.py | 56 ++++ .../Python/rips/taskmaestro_helper/run.py | 126 +++++++++ .../rips/tests/test_taskmaestro_helper.py | 159 +++++++++++ 33 files changed, 1891 insertions(+) create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/CMakeLists_files.cmake create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.cpp create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.h create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowBoolBinding.cpp create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowBoolBinding.h create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCaseBinding.cpp create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCaseBinding.h create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCollection.cpp create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCollection.h create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFieldBinding.cpp create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFieldBinding.h create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowNumberBinding.cpp create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowNumberBinding.h create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowStringBinding.cpp create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowStringBinding.h create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.cpp create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.h create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowViewBinding.cpp create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowViewBinding.h create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowWellPathBinding.cpp create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowWellPathBinding.h create mode 100644 ApplicationLibCode/UserInterface/RiuWorkflowRunDialog.cpp create mode 100644 ApplicationLibCode/UserInterface/RiuWorkflowRunDialog.h create mode 100644 GrpcInterface/Python/rips/taskmaestro_helper/__init__.py create mode 100644 GrpcInterface/Python/rips/taskmaestro_helper/__main__.py create mode 100644 GrpcInterface/Python/rips/taskmaestro_helper/introspect.py create mode 100644 GrpcInterface/Python/rips/taskmaestro_helper/refs.py create mode 100644 GrpcInterface/Python/rips/taskmaestro_helper/run.py create mode 100644 GrpcInterface/Python/rips/tests/test_taskmaestro_helper.py 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/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..0c7d7ee80f4 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/CMakeLists_files.cmake @@ -0,0 +1,35 @@ +set(SOURCE_GROUP_HEADER_FILES + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflow.h + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowCollection.h + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowTaskInput.h + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowFieldBinding.h + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowStringBinding.h + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowNumberBinding.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 +) + +set(SOURCE_GROUP_SOURCE_FILES + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflow.cpp + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowCollection.cpp + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowTaskInput.cpp + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowFieldBinding.cpp + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowStringBinding.cpp + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowNumberBinding.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 +) + +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..5a7068d5eb2 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.cpp @@ -0,0 +1,261 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RiaApplication.h" +#include "RiaLogging.h" +#include "RiaPreferences.h" + +#include "RiuMainWindow.h" +#include "RiuWorkflowRunDialog.h" + +#include "cafPdmUiPushButtonEditor.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +CAF_PDM_SOURCE_INIT( RimWorkflow, "Workflow" ); + +namespace +{ +QString 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 {}; +} +} // namespace + +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_InitField( &m_runButton, "RunButton", false, "Run" ); + m_runButton.uiCapability()->setUiEditorTypeName( caf::PdmUiPushButtonEditor::uiEditorTypeName() ); + m_runButton.xmlCapability()->disableIO(); + + CAF_PDM_InitFieldNoDefault( &m_taskInputs, "TaskInputs", "" ); +} + +void RimWorkflow::fieldChangedByUi( const caf::PdmFieldHandle* changedField, const QVariant& oldValue, const QVariant& newValue ) +{ + if ( changedField == &m_runButton ) + { + m_runButton = false; + runWorkflow(); + } +} + +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::taskInputs() const +{ + std::vector result; + result.reserve( m_taskInputs.size() ); + for ( RimWorkflowTaskInput* t : m_taskInputs.childrenByType() ) + { + if ( t ) result.push_back( t ); + } + return result; +} + +bool RimWorkflow::loadFromDirectory( QString* errorMessage ) +{ + m_taskInputs.deleteChildren(); + m_loadError = ""; + + const QString dir = workflowDirectory(); + if ( dir.isEmpty() ) return false; + + QString python = 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() ); + + 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() ); + m_taskInputs.push_back( input ); + } + + RiaLogging::info( + QString( "Loaded workflow '%1' (%2 tasks with config) from %3" ).arg( m_name() ).arg( m_taskInputs.size() ).arg( dir ).toStdString() ); + + return true; +} + +QString RimWorkflow::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 RimWorkflow::runWorkflow() +{ + const QString dir = 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 = 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(); + + auto* dialog = new RiuWorkflowRunDialog( m_name(), RiuMainWindow::instance() ); + dialog->setAttribute( Qt::WA_DeleteOnClose ); + dialog->show(); + dialog->start( python, args, env ); +} diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.h new file mode 100644 index 00000000000..54d38cf0121 --- /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 "RimWorkflowTaskInput.h" + +#include "cafFilePath.h" +#include "cafPdmChildArrayField.h" +#include "cafPdmField.h" +#include "cafPdmObject.h" + +class RimWorkflow : public caf::PdmObject +{ + CAF_PDM_HEADER_INIT; + +public: + RimWorkflow(); + + void setWorkflowDirectory( const QString& directory ); + QString workflowDirectory() const; + + bool loadFromDirectory( QString* errorMessage = nullptr ); + QString writeInputYaml( const QString& path ) const; + + std::vector taskInputs() const; + + void runWorkflow(); + +protected: + void fieldChangedByUi( const caf::PdmFieldHandle* changedField, const QVariant& oldValue, const QVariant& newValue ) override; + +private: + caf::PdmField m_name; + caf::PdmField m_description; + caf::PdmField m_workflowDirectory; + caf::PdmField m_loadError; + caf::PdmField m_runButton; + caf::PdmChildArrayField m_taskInputs; +}; 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..d0e6f67ef89 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowBoolBinding.h @@ -0,0 +1,35 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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; + +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..8f4100d266d --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCaseBinding.h @@ -0,0 +1,40 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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; + +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/RimWorkflowFieldBinding.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFieldBinding.cpp new file mode 100644 index 00000000000..cc4bd9e2e22 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFieldBinding.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 "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 ) ); +} diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFieldBinding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFieldBinding.h new file mode 100644 index 00000000000..3336847ff6b --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFieldBinding.h @@ -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. +// +///////////////////////////////////////////////////////////////////////////////// + +#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; + +protected: + caf::PdmField m_fieldName; + caf::PdmField m_description; + caf::PdmField m_required; +}; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowNumberBinding.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowNumberBinding.cpp new file mode 100644 index 00000000000..d43102cacb6 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowNumberBinding.cpp @@ -0,0 +1,50 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026 Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#include "RimWorkflowNumberBinding.h" + +#include + +CAF_PDM_SOURCE_INIT( RimWorkflowNumberBinding, "WorkflowNumberBinding" ); + +RimWorkflowNumberBinding::RimWorkflowNumberBinding() + : m_isInteger( false ) +{ + CAF_PDM_InitField( &m_value, "Value", 0.0, "Value" ); +} + +void RimWorkflowNumberBinding::setIsInteger( bool isInteger ) +{ + m_isInteger = isInteger; +} + +void RimWorkflowNumberBinding::applySchema( const QJsonObject& fieldSchema ) +{ + RimWorkflowFieldBinding::applySchema( fieldSchema ); + setIsInteger( fieldSchema.value( "type" ).toString() == "integer" ); + if ( fieldSchema.contains( "default" ) ) + { + m_value = fieldSchema.value( "default" ).toDouble(); + } +} + +QString RimWorkflowNumberBinding::toYamlValue() const +{ + if ( m_isInteger ) return QString::number( static_cast( m_value() ) ); + return QString::number( m_value(), 'g', 17 ); +} diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowNumberBinding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowNumberBinding.h new file mode 100644 index 00000000000..47577b6e61f --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowNumberBinding.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 RimWorkflowNumberBinding : public RimWorkflowFieldBinding +{ + CAF_PDM_HEADER_INIT; + +public: + RimWorkflowNumberBinding(); + + void setIsInteger( bool isInteger ); + void applySchema( const QJsonObject& fieldSchema ) override; + QString toYamlValue() const override; + +private: + caf::PdmField m_value; + bool m_isInteger; +}; 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..a0bc83632b3 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowStringBinding.h @@ -0,0 +1,35 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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; + +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..34a0c729064 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.cpp @@ -0,0 +1,94 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RimWorkflowNumberBinding.h" +#include "RimWorkflowStringBinding.h" +#include "RimWorkflowViewBinding.h" +#include "RimWorkflowWellPathBinding.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" ); + if ( type == "boolean" ) return new RimWorkflowBoolBinding; + if ( type == "number" || type == "integer" ) return new RimWorkflowNumberBinding; + 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 ); + } +} + +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..ad75979398a --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.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 "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; + +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..40b6a6edffd --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowViewBinding.h @@ -0,0 +1,40 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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; + +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..ab04ea4fabd --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowWellPathBinding.h @@ -0,0 +1,40 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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; + +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..1d235ce006e 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}/RiuWorkflowRunDialog.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}/RiuWorkflowRunDialog.cpp ${CMAKE_CURRENT_LIST_DIR}/RiuPlotObjectPicker.cpp ${CMAKE_CURRENT_LIST_DIR}/RiuContextMenuLauncher.cpp ${CMAKE_CURRENT_LIST_DIR}/RiuSummaryVectorSelectionUi.cpp diff --git a/ApplicationLibCode/UserInterface/RiuWorkflowRunDialog.cpp b/ApplicationLibCode/UserInterface/RiuWorkflowRunDialog.cpp new file mode 100644 index 00000000000..e4e3c4bea47 --- /dev/null +++ b/ApplicationLibCode/UserInterface/RiuWorkflowRunDialog.cpp @@ -0,0 +1,108 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RiuWorkflowRunDialog.h" + +#include +#include +#include +#include +#include + +RiuWorkflowRunDialog::RiuWorkflowRunDialog( const QString& workflowName, QWidget* parent ) + : QDialog( parent ) +{ + setWindowTitle( QString( "Run Workflow: %1" ).arg( workflowName ) ); + resize( 720, 480 ); + setModal( false ); + + m_log = new QPlainTextEdit( this ); + m_log->setReadOnly( true ); + m_log->setFont( QFontDatabase::systemFont( QFontDatabase::FixedFont ) ); + + m_cancelButton = new QPushButton( "Cancel", this ); + m_closeButton = new QPushButton( "Close", this ); + m_closeButton->setEnabled( false ); + + auto* buttons = new QHBoxLayout; + buttons->addStretch(); + buttons->addWidget( m_cancelButton ); + buttons->addWidget( m_closeButton ); + + auto* layout = new QVBoxLayout( this ); + layout->addWidget( m_log ); + layout->addLayout( buttons ); + + connect( m_cancelButton, &QPushButton::clicked, this, &RiuWorkflowRunDialog::onCancelClicked ); + connect( m_closeButton, &QPushButton::clicked, this, &QDialog::accept ); + + connect( &m_process, &QProcess::readyReadStandardOutput, this, &RiuWorkflowRunDialog::onReadyReadStdout ); + connect( &m_process, &QProcess::readyReadStandardError, this, &RiuWorkflowRunDialog::onReadyReadStderr ); + connect( &m_process, QOverload::of( &QProcess::finished ), this, &RiuWorkflowRunDialog::onProcessFinished ); +} + +RiuWorkflowRunDialog::~RiuWorkflowRunDialog() +{ + if ( m_process.state() != QProcess::NotRunning ) + { + m_process.kill(); + m_process.waitForFinished( 2000 ); + } +} + +void RiuWorkflowRunDialog::start( const QString& program, const QStringList& arguments, const QProcessEnvironment& env ) +{ + appendLog( QString( "$ %1 %2" ).arg( program, arguments.join( ' ' ) ) ); + m_process.setProcessEnvironment( env ); + m_process.start( program, arguments ); +} + +void RiuWorkflowRunDialog::onReadyReadStdout() +{ + appendLog( QString::fromUtf8( m_process.readAllStandardOutput() ) ); +} + +void RiuWorkflowRunDialog::onReadyReadStderr() +{ + appendLog( QString::fromUtf8( m_process.readAllStandardError() ) ); +} + +void RiuWorkflowRunDialog::onProcessFinished( int exitCode, QProcess::ExitStatus status ) +{ + QString tail = ( status == QProcess::NormalExit ) ? QString( "[exited with code %1]" ).arg( exitCode ) : QString( "[crashed]" ); + appendLog( "\n" + tail ); + m_cancelButton->setEnabled( false ); + m_closeButton->setEnabled( true ); +} + +void RiuWorkflowRunDialog::onCancelClicked() +{ + if ( m_process.state() != QProcess::NotRunning ) + { + m_process.terminate(); + if ( !m_process.waitForFinished( 2000 ) ) m_process.kill(); + } +} + +void RiuWorkflowRunDialog::appendLog( const QString& text ) +{ + if ( text.isEmpty() ) return; + m_log->moveCursor( QTextCursor::End ); + m_log->insertPlainText( text ); + m_log->moveCursor( QTextCursor::End ); +} diff --git a/ApplicationLibCode/UserInterface/RiuWorkflowRunDialog.h b/ApplicationLibCode/UserInterface/RiuWorkflowRunDialog.h new file mode 100644 index 00000000000..48b651223cb --- /dev/null +++ b/ApplicationLibCode/UserInterface/RiuWorkflowRunDialog.h @@ -0,0 +1,49 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 +#include + +class QPlainTextEdit; +class QPushButton; + +class RiuWorkflowRunDialog : public QDialog +{ + Q_OBJECT +public: + explicit RiuWorkflowRunDialog( const QString& workflowName, QWidget* parent = nullptr ); + ~RiuWorkflowRunDialog() override; + + void start( const QString& program, const QStringList& arguments, const QProcessEnvironment& env ); + +private slots: + void onReadyReadStdout(); + void onReadyReadStderr(); + void onProcessFinished( int exitCode, QProcess::ExitStatus status ); + void onCancelClicked(); + +private: + void appendLog( const QString& text ); + + QProcess m_process; + QPlainTextEdit* m_log; + QPushButton* m_cancelButton; + QPushButton* m_closeButton; +}; 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..3fc4ca1e646 --- /dev/null +++ b/GrpcInterface/Python/rips/taskmaestro_helper/introspect.py @@ -0,0 +1,138 @@ +"""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 json +import sys +from pathlib import Path +from types import UnionType +from typing import Any, Union, get_args, get_origin + +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 _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" + + +def _field_schema(field_name: str, field_info: FieldInfo) -> 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 + if not field_info.is_required(): + default = field_info.get_default(call_default_factory=False) + if default is not None: + entry["default"] = default + 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, _ = _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 + + 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 + config_fields.append(_field_schema(field_name, field_info)) + + 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..7b05dfe3d2d --- /dev/null +++ b/GrpcInterface/Python/rips/tests/test_taskmaestro_helper.py @@ -0,0 +1,159 @@ +"""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 + +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) + + +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] +""" + + +SYNTHETIC_INPUT_YAML = """greet: + name: alice + times: 3 +""" + + +@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"]} + + assert fields_by_name["name"]["type"] == "string" + assert fields_by_name["name"]["default"] == "world" + assert fields_by_name["name"]["description"] == "Person to greet" + assert fields_by_name["name"]["required"] is False + + assert fields_by_name["times"]["type"] == "integer" + assert fields_by_name["times"]["required"] is True + assert "resinsight_type" not in fields_by_name["times"] + + +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"} From b2ab54d5b464f2722e8c9954594c32675d58fb30 Mon Sep 17 00:00:00 2001 From: Kristian Bendiksen Date: Fri, 15 May 2026 15:43:58 +0200 Subject: [PATCH 2/9] Workflows: Add date picker for datetime-typed config fields The introspect helper now detects datetime.date / datetime.datetime field annotations and emits format=date / date-time on the schema entry. ResInsight dispatches that to a new RimWorkflowDateBinding backed by caf::PdmField, which auto-renders as a calendar widget via the framework's PdmUiDateEditor. toYamlValue emits an ISO-formatted date so Pydantic's date validator on the workflow side accepts it without further conversion. --- .../Workflow/CMakeLists_files.cmake | 2 + .../Workflow/RimWorkflowDateBinding.cpp | 43 +++++++++++++++++++ .../Workflow/RimWorkflowDateBinding.h | 37 ++++++++++++++++ .../Workflow/RimWorkflowTaskInput.cpp | 5 ++- .../rips/taskmaestro_helper/introspect.py | 12 ++++++ .../rips/tests/test_taskmaestro_helper.py | 9 +++- 6 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDateBinding.cpp create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDateBinding.h diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/CMakeLists_files.cmake b/ApplicationLibCode/ProjectDataModel/Workflow/CMakeLists_files.cmake index 0c7d7ee80f4..7017ab14bcf 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/CMakeLists_files.cmake +++ b/ApplicationLibCode/ProjectDataModel/Workflow/CMakeLists_files.cmake @@ -9,6 +9,7 @@ set(SOURCE_GROUP_HEADER_FILES ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowCaseBinding.h ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowWellPathBinding.h ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowViewBinding.h + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowDateBinding.h ) set(SOURCE_GROUP_SOURCE_FILES @@ -22,6 +23,7 @@ set(SOURCE_GROUP_SOURCE_FILES ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowCaseBinding.cpp ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowWellPathBinding.cpp ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowViewBinding.cpp + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowDateBinding.cpp ) list(APPEND CODE_HEADER_FILES ${SOURCE_GROUP_HEADER_FILES}) 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..a320d27b636 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDateBinding.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" + +#include + +class RimWorkflowDateBinding : public RimWorkflowFieldBinding +{ + CAF_PDM_HEADER_INIT; + +public: + RimWorkflowDateBinding(); + + void applySchema( const QJsonObject& fieldSchema ) override; + QString toYamlValue() const override; + +private: + caf::PdmField m_value; +}; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.cpp index 34a0c729064..87b8257c04a 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.cpp @@ -20,6 +20,7 @@ #include "RimWorkflowBoolBinding.h" #include "RimWorkflowCaseBinding.h" +#include "RimWorkflowDateBinding.h" #include "RimWorkflowNumberBinding.h" #include "RimWorkflowStringBinding.h" #include "RimWorkflowViewBinding.h" @@ -39,7 +40,9 @@ RimWorkflowFieldBinding* createBinding( const QJsonObject& schema ) if ( resinsightType == "WellPath" ) return new RimWorkflowWellPathBinding; if ( resinsightType == "View" ) return new RimWorkflowViewBinding; - const QString type = schema.value( "type" ).toString( "string" ); + 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 == "boolean" ) return new RimWorkflowBoolBinding; if ( type == "number" || type == "integer" ) return new RimWorkflowNumberBinding; return new RimWorkflowStringBinding; diff --git a/GrpcInterface/Python/rips/taskmaestro_helper/introspect.py b/GrpcInterface/Python/rips/taskmaestro_helper/introspect.py index 3fc4ca1e646..0cdfb99eb04 100644 --- a/GrpcInterface/Python/rips/taskmaestro_helper/introspect.py +++ b/GrpcInterface/Python/rips/taskmaestro_helper/introspect.py @@ -9,6 +9,7 @@ from __future__ import annotations +import datetime import json import sys from pathlib import Path @@ -27,6 +28,12 @@ float: "number", } +# Annotations reported as JSON-schema "string" with an extra `format` marker. +_DATE_FORMAT_MAP: dict[type, str] = { + datetime.date: "date", + datetime.datetime: "date-time", +} + def _annotation_type(annotation: object) -> str: """Map a Pydantic field annotation to a JSON-schema-style type label.""" @@ -62,7 +69,12 @@ def _field_schema(field_name: str, field_info: FieldInfo) -> dict[str, Any]: if not field_info.is_required(): default = field_info.get_default(call_default_factory=False) if default is not None: + if isinstance(default, (datetime.date, datetime.datetime)): + default = default.isoformat() entry["default"] = default + fmt = _DATE_FORMAT_MAP.get(field_info.annotation) + 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 diff --git a/GrpcInterface/Python/rips/tests/test_taskmaestro_helper.py b/GrpcInterface/Python/rips/tests/test_taskmaestro_helper.py index 7b05dfe3d2d..923923588c6 100644 --- a/GrpcInterface/Python/rips/tests/test_taskmaestro_helper.py +++ b/GrpcInterface/Python/rips/tests/test_taskmaestro_helper.py @@ -22,6 +22,8 @@ from __future__ import annotations +import datetime + from pydantic import BaseModel, Field from taskmaestro import EmptyConfig, ExecutionContext, Task @@ -30,6 +32,7 @@ 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)) class GreetOutput(BaseModel): @@ -65,7 +68,7 @@ def run(self, input: StartInput, ctx: ExecutionContext) -> StartOutput: # pragm - task: pipeline.Start - task: pipeline.Greet depends_on: pipeline.Start - config_fields: [name, times] + config_fields: [name, times, when] """ @@ -108,6 +111,10 @@ def test_collect_schema_extracts_field_types_and_metadata(workflow_dir: Path) -> assert fields_by_name["times"]["required"] is True assert "resinsight_type" not in fields_by_name["times"] + assert fields_by_name["when"]["type"] == "string" + assert fields_by_name["when"]["format"] == "date" + assert fields_by_name["when"]["default"] == "2024-01-01" + def test_resolve_refs_substitutes_object_model_value() -> None: from rips.taskmaestro_helper.run import resolve_refs From 770a9e8b8e1a7f7556bfe07dd3bebbe95ae0f29d Mon Sep 17 00:00:00 2001 From: Kristian Bendiksen Date: Fri, 15 May 2026 16:10:54 +0200 Subject: [PATCH 3/9] Workflows: Add file and directory pickers for Path-typed config fields The introspect helper now delegates JSON-schema format extraction to pydantic.TypeAdapter, which uniformly emits format=path, format=file-path, and format=directory-path for pathlib.Path, pydantic.FilePath, and pydantic.DirectoryPath respectively. Pydantic splits Annotated path types into annotation+metadata, so the helper reassembles them before asking TypeAdapter, otherwise DirectoryPath would lose its directory marker. ResInsight dispatches all three formats to a new RimWorkflowFilePathBinding backed by caf::PdmField. defineEditorAttribute toggles m_selectDirectory based on whether the schema's format is "directory-path", swapping the file-open dialog for a directory chooser. --- .../Workflow/CMakeLists_files.cmake | 2 + .../Workflow/RimWorkflowFilePathBinding.cpp | 64 +++++++++++++++++++ .../Workflow/RimWorkflowFilePathBinding.h | 41 ++++++++++++ .../Workflow/RimWorkflowTaskInput.cpp | 3 + .../rips/taskmaestro_helper/introspect.py | 29 +++++++-- .../rips/tests/test_taskmaestro_helper.py | 15 ++++- 6 files changed, 146 insertions(+), 8 deletions(-) create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFilePathBinding.cpp create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFilePathBinding.h diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/CMakeLists_files.cmake b/ApplicationLibCode/ProjectDataModel/Workflow/CMakeLists_files.cmake index 7017ab14bcf..26c14a3fe56 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/CMakeLists_files.cmake +++ b/ApplicationLibCode/ProjectDataModel/Workflow/CMakeLists_files.cmake @@ -10,6 +10,7 @@ set(SOURCE_GROUP_HEADER_FILES ${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 @@ -24,6 +25,7 @@ set(SOURCE_GROUP_SOURCE_FILES ${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}) 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..4947ba9a418 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFilePathBinding.h @@ -0,0 +1,41 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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; + +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/RimWorkflowTaskInput.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.cpp index 87b8257c04a..e17dacac10f 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.cpp @@ -21,6 +21,7 @@ #include "RimWorkflowBoolBinding.h" #include "RimWorkflowCaseBinding.h" #include "RimWorkflowDateBinding.h" +#include "RimWorkflowFilePathBinding.h" #include "RimWorkflowNumberBinding.h" #include "RimWorkflowStringBinding.h" #include "RimWorkflowViewBinding.h" @@ -43,6 +44,8 @@ RimWorkflowFieldBinding* createBinding( const QJsonObject& schema ) 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 == "number" || type == "integer" ) return new RimWorkflowNumberBinding; return new RimWorkflowStringBinding; diff --git a/GrpcInterface/Python/rips/taskmaestro_helper/introspect.py b/GrpcInterface/Python/rips/taskmaestro_helper/introspect.py index 0cdfb99eb04..0cbb22f36e0 100644 --- a/GrpcInterface/Python/rips/taskmaestro_helper/introspect.py +++ b/GrpcInterface/Python/rips/taskmaestro_helper/introspect.py @@ -11,11 +11,13 @@ import datetime import json +import pathlib import sys from pathlib import Path from types import UnionType -from typing import Any, Union, get_args, get_origin +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 @@ -28,11 +30,22 @@ float: "number", } -# Annotations reported as JSON-schema "string" with an extra `format` marker. -_DATE_FORMAT_MAP: dict[type, str] = { - datetime.date: "date", - datetime.datetime: "date-time", -} + +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: @@ -71,8 +84,10 @@ def _field_schema(field_name: str, field_info: FieldInfo) -> dict[str, Any]: if default is not None: if isinstance(default, (datetime.date, datetime.datetime)): default = default.isoformat() + elif isinstance(default, pathlib.PurePath): + default = str(default) entry["default"] = default - fmt = _DATE_FORMAT_MAP.get(field_info.annotation) + fmt = _format_for(field_info) if fmt is not None: entry["format"] = fmt ri_type = annotation_to_resinsight_type(field_info.annotation) diff --git a/GrpcInterface/Python/rips/tests/test_taskmaestro_helper.py b/GrpcInterface/Python/rips/tests/test_taskmaestro_helper.py index 923923588c6..4f1a64d3f53 100644 --- a/GrpcInterface/Python/rips/tests/test_taskmaestro_helper.py +++ b/GrpcInterface/Python/rips/tests/test_taskmaestro_helper.py @@ -23,7 +23,9 @@ from __future__ import annotations import datetime +import pathlib +import pydantic from pydantic import BaseModel, Field from taskmaestro import EmptyConfig, ExecutionContext, Task @@ -33,6 +35,8 @@ 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): @@ -68,13 +72,14 @@ def run(self, input: StartInput, ctx: ExecutionContext) -> StartOutput: # pragm - task: pipeline.Start - task: pipeline.Greet depends_on: pipeline.Start - config_fields: [name, times, when] + config_fields: [name, times, when, out_file, out_dir] """ SYNTHETIC_INPUT_YAML = """greet: name: alice times: 3 + out_dir: /tmp """ @@ -115,6 +120,14 @@ def test_collect_schema_extracts_field_types_and_metadata(workflow_dir: Path) -> assert fields_by_name["when"]["format"] == "date" assert fields_by_name["when"]["default"] == "2024-01-01" + 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" + + 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 + def test_resolve_refs_substitutes_object_model_value() -> None: from rips.taskmaestro_helper.run import resolve_refs From cfad4b4abf323c349524b4a921157a0d2788e673 Mon Sep 17 00:00:00 2001 From: Kristian Bendiksen Date: Fri, 15 May 2026 16:27:17 +0200 Subject: [PATCH 4/9] Workflows: Split NumberBinding into FloatBinding and IntBinding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous RimWorkflowNumberBinding stored everything as caf::PdmField and gated YAML emission on an m_isInteger flag, which meant integer fields rendered in the same free-form decimal text editor as floats. Splitting into RimWorkflowFloatBinding (PdmField) and RimWorkflowIntBinding (PdmField) lets each use the natively-typed editor — integers now render as a spinbox. Dispatch in createBinding picks the right subclass from the schema's type ("integer" or "number") emitted by Pydantic. --- .../Workflow/CMakeLists_files.cmake | 6 ++- ...inding.cpp => RimWorkflowFloatBinding.cpp} | 18 +++----- ...berBinding.h => RimWorkflowFloatBinding.h} | 6 +-- .../Workflow/RimWorkflowIntBinding.cpp | 42 +++++++++++++++++++ .../Workflow/RimWorkflowIntBinding.h | 35 ++++++++++++++++ .../Workflow/RimWorkflowTaskInput.cpp | 6 ++- 6 files changed, 92 insertions(+), 21 deletions(-) rename ApplicationLibCode/ProjectDataModel/Workflow/{RimWorkflowNumberBinding.cpp => RimWorkflowFloatBinding.cpp} (65%) rename ApplicationLibCode/ProjectDataModel/Workflow/{RimWorkflowNumberBinding.h => RimWorkflowFloatBinding.h} (84%) create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowIntBinding.cpp create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowIntBinding.h diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/CMakeLists_files.cmake b/ApplicationLibCode/ProjectDataModel/Workflow/CMakeLists_files.cmake index 26c14a3fe56..921e0864ca8 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/CMakeLists_files.cmake +++ b/ApplicationLibCode/ProjectDataModel/Workflow/CMakeLists_files.cmake @@ -4,7 +4,8 @@ set(SOURCE_GROUP_HEADER_FILES ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowTaskInput.h ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowFieldBinding.h ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowStringBinding.h - ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowNumberBinding.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 @@ -19,7 +20,8 @@ set(SOURCE_GROUP_SOURCE_FILES ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowTaskInput.cpp ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowFieldBinding.cpp ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowStringBinding.cpp - ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowNumberBinding.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 diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowNumberBinding.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFloatBinding.cpp similarity index 65% rename from ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowNumberBinding.cpp rename to ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFloatBinding.cpp index d43102cacb6..7777f949339 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowNumberBinding.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFloatBinding.cpp @@ -16,35 +16,27 @@ // ///////////////////////////////////////////////////////////////////////////////// -#include "RimWorkflowNumberBinding.h" +#include "RimWorkflowFloatBinding.h" #include -CAF_PDM_SOURCE_INIT( RimWorkflowNumberBinding, "WorkflowNumberBinding" ); +CAF_PDM_SOURCE_INIT( RimWorkflowFloatBinding, "WorkflowFloatBinding" ); -RimWorkflowNumberBinding::RimWorkflowNumberBinding() - : m_isInteger( false ) +RimWorkflowFloatBinding::RimWorkflowFloatBinding() { CAF_PDM_InitField( &m_value, "Value", 0.0, "Value" ); } -void RimWorkflowNumberBinding::setIsInteger( bool isInteger ) -{ - m_isInteger = isInteger; -} - -void RimWorkflowNumberBinding::applySchema( const QJsonObject& fieldSchema ) +void RimWorkflowFloatBinding::applySchema( const QJsonObject& fieldSchema ) { RimWorkflowFieldBinding::applySchema( fieldSchema ); - setIsInteger( fieldSchema.value( "type" ).toString() == "integer" ); if ( fieldSchema.contains( "default" ) ) { m_value = fieldSchema.value( "default" ).toDouble(); } } -QString RimWorkflowNumberBinding::toYamlValue() const +QString RimWorkflowFloatBinding::toYamlValue() const { - if ( m_isInteger ) return QString::number( static_cast( m_value() ) ); return QString::number( m_value(), 'g', 17 ); } diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowNumberBinding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFloatBinding.h similarity index 84% rename from ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowNumberBinding.h rename to ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFloatBinding.h index 47577b6e61f..8cefe5bac31 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowNumberBinding.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFloatBinding.h @@ -20,18 +20,16 @@ #include "RimWorkflowFieldBinding.h" -class RimWorkflowNumberBinding : public RimWorkflowFieldBinding +class RimWorkflowFloatBinding : public RimWorkflowFieldBinding { CAF_PDM_HEADER_INIT; public: - RimWorkflowNumberBinding(); + RimWorkflowFloatBinding(); - void setIsInteger( bool isInteger ); void applySchema( const QJsonObject& fieldSchema ) override; QString toYamlValue() const override; private: caf::PdmField m_value; - bool m_isInteger; }; 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..b02b75f3281 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowIntBinding.h @@ -0,0 +1,35 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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; + +private: + caf::PdmField m_value; +}; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.cpp index e17dacac10f..d2a8546813e 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.cpp @@ -22,7 +22,8 @@ #include "RimWorkflowCaseBinding.h" #include "RimWorkflowDateBinding.h" #include "RimWorkflowFilePathBinding.h" -#include "RimWorkflowNumberBinding.h" +#include "RimWorkflowFloatBinding.h" +#include "RimWorkflowIntBinding.h" #include "RimWorkflowStringBinding.h" #include "RimWorkflowViewBinding.h" #include "RimWorkflowWellPathBinding.h" @@ -47,7 +48,8 @@ RimWorkflowFieldBinding* createBinding( const QJsonObject& schema ) if ( type == "string" && ( format == "path" || format == "file-path" || format == "directory-path" ) ) return new RimWorkflowFilePathBinding; if ( type == "boolean" ) return new RimWorkflowBoolBinding; - if ( type == "number" || type == "integer" ) return new RimWorkflowNumberBinding; + if ( type == "integer" ) return new RimWorkflowIntBinding; + if ( type == "number" ) return new RimWorkflowFloatBinding; return new RimWorkflowStringBinding; } } // namespace From 7dd4c303f9ddf4557906d8cb2619bac0497914d7 Mon Sep 17 00:00:00 2001 From: Kristian Bendiksen Date: Fri, 15 May 2026 17:46:56 +0200 Subject: [PATCH 5/9] Workflows: Pre-fill bindings with values from input.yaml The introspect helper now captures the JobConfiguration that taskmaestro builds from input.yaml and merges its per-task field values into each schema entry's `default`. ResInsight's existing applySchema() methods already consume `default` to populate their PdmField values, so e.g. add_perf_1.start_md = 3000.0 from the bundled example now shows up pre-filled in the property editor instead of starting at zero. The Pydantic-side default still flows through whenever input.yaml does not provide a value, so adding an input file is purely additive. Date and Path values are normalised through a shared _serialize_default helper so they round-trip cleanly through JSON. --- .../rips/taskmaestro_helper/introspect.py | 39 ++++++++++++++----- .../rips/tests/test_taskmaestro_helper.py | 9 ++++- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/GrpcInterface/Python/rips/taskmaestro_helper/introspect.py b/GrpcInterface/Python/rips/taskmaestro_helper/introspect.py index 0cbb22f36e0..00a9a842ff5 100644 --- a/GrpcInterface/Python/rips/taskmaestro_helper/introspect.py +++ b/GrpcInterface/Python/rips/taskmaestro_helper/introspect.py @@ -71,7 +71,21 @@ def _annotation_type(annotation: object) -> str: return "string" -def _field_schema(field_name: str, field_info: FieldInfo) -> dict[str, Any]: +_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), @@ -79,14 +93,16 @@ def _field_schema(field_name: str, field_info: FieldInfo) -> dict[str, Any]: } if field_info.description: entry["description"] = field_info.description - if not field_info.is_required(): + + # 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: - if isinstance(default, (datetime.date, datetime.datetime)): - default = default.isoformat() - elif isinstance(default, pathlib.PurePath): - default = str(default) - entry["default"] = default + entry["default"] = _serialize_default(default) + fmt = _format_for(field_info) if fmt is not None: entry["format"] = fmt @@ -112,7 +128,7 @@ def collect_schema(workflow_dir: Path) -> dict[str, Any]: from taskmaestro.task import get_input_type from taskmaestro.yaml_config import _load_workflow_only - wf, _ = _load_workflow_only( + wf, jc = _load_workflow_only( workflow_yaml, input_yaml if input_yaml.is_file() else None, ) @@ -123,6 +139,8 @@ def collect_schema(workflow_dir: Path) -> dict[str, Any]: 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): @@ -137,7 +155,10 @@ def collect_schema(workflow_dir: Path) -> dict[str, Any]: } ) continue - config_fields.append(_field_schema(field_name, field_info)) + 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}) diff --git a/GrpcInterface/Python/rips/tests/test_taskmaestro_helper.py b/GrpcInterface/Python/rips/tests/test_taskmaestro_helper.py index 4f1a64d3f53..bbf94a2e585 100644 --- a/GrpcInterface/Python/rips/tests/test_taskmaestro_helper.py +++ b/GrpcInterface/Python/rips/tests/test_taskmaestro_helper.py @@ -107,26 +107,33 @@ def test_collect_schema_extracts_field_types_and_metadata(workflow_dir: Path) -> [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"] == "world" + 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 ecf0c539b1b1dd8b4f46595b043b736279f439b7 Mon Sep 17 00:00:00 2001 From: Kristian Bendiksen Date: Mon, 18 May 2026 14:15:20 +0200 Subject: [PATCH 6/9] Workflows: Split Workflow from Job and consolidate task editor A discovered workflow is now a read-only template that owns one or more RimWorkflowJob configurations, and the Run button lives on the job. New jobs are created from a "New Job" context-menu entry on the workflow (RicNewWorkflowJobFeature), which clones the first job via XML serialization and resolves PDM references. Each task's bindings render as a single property editor on the job, grouped per task. RimWorkflowFieldBinding exposes a valueField() hook so the binding's value field surfaces in the parent ordering with the binding name as label and description as tooltip. Task inputs are hidden from the project tree. --- ApplicationLibCode/Commands/CMakeLists.txt | 1 + .../WorkflowCommands/CMakeLists_files.cmake | 6 + .../RicNewWorkflowJobFeature.cpp | 65 ++++++ .../RicNewWorkflowJobFeature.h | 31 +++ .../Workflow/CMakeLists_files.cmake | 2 + .../ProjectDataModel/Workflow/RimWorkflow.cpp | 116 +++------- .../ProjectDataModel/Workflow/RimWorkflow.h | 26 +-- .../Workflow/RimWorkflowBoolBinding.h | 2 + .../Workflow/RimWorkflowCaseBinding.h | 2 + .../Workflow/RimWorkflowDateBinding.h | 2 + .../Workflow/RimWorkflowFieldBinding.cpp | 6 + .../Workflow/RimWorkflowFieldBinding.h | 2 + .../Workflow/RimWorkflowFilePathBinding.h | 2 + .../Workflow/RimWorkflowFloatBinding.h | 2 + .../Workflow/RimWorkflowIntBinding.h | 2 + .../Workflow/RimWorkflowJob.cpp | 214 ++++++++++++++++++ .../Workflow/RimWorkflowJob.h | 52 +++++ .../Workflow/RimWorkflowStringBinding.h | 2 + .../Workflow/RimWorkflowTaskInput.cpp | 17 ++ .../Workflow/RimWorkflowTaskInput.h | 4 + .../Workflow/RimWorkflowViewBinding.h | 2 + .../Workflow/RimWorkflowWellPathBinding.h | 2 + 22 files changed, 461 insertions(+), 99 deletions(-) create mode 100644 ApplicationLibCode/Commands/WorkflowCommands/CMakeLists_files.cmake create mode 100644 ApplicationLibCode/Commands/WorkflowCommands/RicNewWorkflowJobFeature.cpp create mode 100644 ApplicationLibCode/Commands/WorkflowCommands/RicNewWorkflowJobFeature.h create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.cpp create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.h 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..a2fbfb09e0e --- /dev/null +++ b/ApplicationLibCode/Commands/WorkflowCommands/CMakeLists_files.cmake @@ -0,0 +1,6 @@ +set(SOURCE_GROUP_HEADER_FILES ${CMAKE_CURRENT_LIST_DIR}/RicNewWorkflowJobFeature.h) + +set(SOURCE_GROUP_SOURCE_FILES ${CMAKE_CURRENT_LIST_DIR}/RicNewWorkflowJobFeature.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/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/ProjectDataModel/Workflow/CMakeLists_files.cmake b/ApplicationLibCode/ProjectDataModel/Workflow/CMakeLists_files.cmake index 921e0864ca8..42a9f7bc119 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/CMakeLists_files.cmake +++ b/ApplicationLibCode/ProjectDataModel/Workflow/CMakeLists_files.cmake @@ -1,6 +1,7 @@ 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 @@ -17,6 +18,7 @@ set(SOURCE_GROUP_HEADER_FILES 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 diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.cpp index 5a7068d5eb2..cf6cec2e5ab 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.cpp @@ -18,16 +18,14 @@ #include "RimWorkflow.h" -#include "RiaApplication.h" +#include "RimWorkflowJob.h" +#include "RimWorkflowTaskInput.h" + #include "RiaLogging.h" #include "RiaPreferences.h" -#include "RiuMainWindow.h" -#include "RiuWorkflowRunDialog.h" - -#include "cafPdmUiPushButtonEditor.h" +#include "cafCmdFeatureMenuBuilder.h" -#include #include #include #include @@ -36,8 +34,6 @@ #include #include #include -#include -#include CAF_PDM_SOURCE_INIT( RimWorkflow, "Workflow" ); @@ -84,20 +80,12 @@ RimWorkflow::RimWorkflow() CAF_PDM_InitFieldNoDefault( &m_loadError, "LoadError", "Load Error" ); m_loadError.uiCapability()->setUiReadOnly( true ); - CAF_PDM_InitField( &m_runButton, "RunButton", false, "Run" ); - m_runButton.uiCapability()->setUiEditorTypeName( caf::PdmUiPushButtonEditor::uiEditorTypeName() ); - m_runButton.xmlCapability()->disableIO(); - - CAF_PDM_InitFieldNoDefault( &m_taskInputs, "TaskInputs", "" ); + CAF_PDM_InitFieldNoDefault( &m_jobs, "Jobs", "" ); } -void RimWorkflow::fieldChangedByUi( const caf::PdmFieldHandle* changedField, const QVariant& oldValue, const QVariant& newValue ) +QString RimWorkflow::name() const { - if ( changedField == &m_runButton ) - { - m_runButton = false; - runWorkflow(); - } + return m_name(); } void RimWorkflow::setWorkflowDirectory( const QString& directory ) @@ -113,20 +101,30 @@ QString RimWorkflow::workflowDirectory() const return m_workflowDirectory().path(); } -std::vector RimWorkflow::taskInputs() const +std::vector RimWorkflow::jobs() const { - std::vector result; - result.reserve( m_taskInputs.size() ); - for ( RimWorkflowTaskInput* t : m_taskInputs.childrenByType() ) + std::vector result; + result.reserve( m_jobs.size() ); + for ( RimWorkflowJob* j : m_jobs.childrenByType() ) { - if ( t ) result.push_back( t ); + 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_taskInputs.deleteChildren(); + m_jobs.deleteChildren(); m_loadError = ""; const QString dir = workflowDirectory(); @@ -187,75 +185,23 @@ bool RimWorkflow::loadFromDirectory( QString* errorMessage ) 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() ); - m_taskInputs.push_back( input ); + 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( m_taskInputs.size() ).arg( dir ).toStdString() ); + QString( "Loaded workflow '%1' (%2 tasks with config) from %3" ).arg( m_name() ).arg( taskInputs.size() ).arg( dir ).toStdString() ); return true; } - -QString RimWorkflow::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 RimWorkflow::runWorkflow() -{ - const QString dir = 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 = 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(); - - auto* dialog = new RiuWorkflowRunDialog( m_name(), RiuMainWindow::instance() ); - dialog->setAttribute( Qt::WA_DeleteOnClose ); - dialog->show(); - dialog->start( python, args, env ); -} diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.h index 54d38cf0121..61eaa1ae745 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.h @@ -18,13 +18,13 @@ #pragma once -#include "RimWorkflowTaskInput.h" - #include "cafFilePath.h" #include "cafPdmChildArrayField.h" #include "cafPdmField.h" #include "cafPdmObject.h" +class RimWorkflowJob; + class RimWorkflow : public caf::PdmObject { CAF_PDM_HEADER_INIT; @@ -32,24 +32,22 @@ class RimWorkflow : public caf::PdmObject public: RimWorkflow(); + QString name() const; void setWorkflowDirectory( const QString& directory ); QString workflowDirectory() const; - bool loadFromDirectory( QString* errorMessage = nullptr ); - QString writeInputYaml( const QString& path ) const; - - std::vector taskInputs() const; + bool loadFromDirectory( QString* errorMessage = nullptr ); - void runWorkflow(); + std::vector jobs() const; + void addJob( RimWorkflowJob* job ); protected: - void fieldChangedByUi( const caf::PdmFieldHandle* changedField, const QVariant& oldValue, const QVariant& newValue ) override; + 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::PdmField m_runButton; - caf::PdmChildArrayField m_taskInputs; + 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.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowBoolBinding.h index d0e6f67ef89..7dceffe2daa 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowBoolBinding.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowBoolBinding.h @@ -30,6 +30,8 @@ class RimWorkflowBoolBinding : public RimWorkflowFieldBinding 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.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCaseBinding.h index 8f4100d266d..18efaa096f8 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCaseBinding.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCaseBinding.h @@ -33,6 +33,8 @@ class RimWorkflowCaseBinding : public RimWorkflowFieldBinding QString toYamlValue() const override; + caf::PdmFieldHandle* valueField() override { return &m_case; } + private: QList calculateValueOptions( const caf::PdmFieldHandle* fieldNeedingOptions ) override; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDateBinding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDateBinding.h index a320d27b636..f9e88fd3da3 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDateBinding.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDateBinding.h @@ -32,6 +32,8 @@ class RimWorkflowDateBinding : public RimWorkflowFieldBinding 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 index cc4bd9e2e22..1ab79ac0ff4 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFieldBinding.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFieldBinding.cpp @@ -62,4 +62,10 @@ 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 index 3336847ff6b..04c412bcf01 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFieldBinding.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFieldBinding.h @@ -38,6 +38,8 @@ class RimWorkflowFieldBinding : public caf::PdmObject 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; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFilePathBinding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFilePathBinding.h index 4947ba9a418..60ae3e8753e 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFilePathBinding.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFilePathBinding.h @@ -32,6 +32,8 @@ class RimWorkflowFilePathBinding : public RimWorkflowFieldBinding 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; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFloatBinding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFloatBinding.h index 8cefe5bac31..c0607cc7de2 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFloatBinding.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFloatBinding.h @@ -30,6 +30,8 @@ class RimWorkflowFloatBinding : public RimWorkflowFieldBinding 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.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowIntBinding.h index b02b75f3281..202316c95ac 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowIntBinding.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowIntBinding.h @@ -30,6 +30,8 @@ class RimWorkflowIntBinding : public RimWorkflowFieldBinding 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..5da56199419 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.cpp @@ -0,0 +1,214 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RiaPreferences.h" + +#include "RiuMainWindow.h" +#include "RiuWorkflowRunDialog.h" + +#include "cafPdmUiGroup.h" +#include "cafPdmUiOrdering.h" +#include "cafPdmUiPushButtonEditor.h" +#include "cafPdmUiTreeOrdering.h" + +#include +#include +#include +#include +#include +#include + +CAF_PDM_SOURCE_INIT( RimWorkflowJob, "WorkflowJob" ); + +namespace +{ +QString 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 {}; +} +} // namespace + +RimWorkflowJob::RimWorkflowJob() +{ + CAF_PDM_InitObject( "Job", ":/Bullet.png" ); + + CAF_PDM_InitFieldNoDefault( &m_name, "Name", "Name" ); + + CAF_PDM_InitField( &m_runButton, "RunButton", false, "Run" ); + m_runButton.uiCapability()->setUiEditorTypeName( caf::PdmUiPushButtonEditor::uiEditorTypeName() ); + m_runButton.xmlCapability()->disableIO(); + + 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 ); + uiOrdering.add( &m_runButton ); + + 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_runButton ) + { + m_runButton = false; + runJob(); + } + else if ( changedField == &m_name ) + { + setUiName( m_name() ); + uiCapability()->updateConnectedEditors(); + } +} + +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() +{ + 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 = 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(); + + auto* dialog = new RiuWorkflowRunDialog( workflow->uiName() + " / " + m_name(), RiuMainWindow::instance() ); + dialog->setAttribute( Qt::WA_DeleteOnClose ); + dialog->show(); + dialog->start( python, args, env ); +} diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.h new file mode 100644 index 00000000000..1171b701127 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.h @@ -0,0 +1,52 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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" + +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(); + +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; + +private: + caf::PdmField m_name; + caf::PdmField m_runButton; + caf::PdmChildArrayField m_taskInputs; +}; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowStringBinding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowStringBinding.h index a0bc83632b3..aaabd427aae 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowStringBinding.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowStringBinding.h @@ -30,6 +30,8 @@ class RimWorkflowStringBinding : public RimWorkflowFieldBinding 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 index d2a8546813e..fe34b256fd6 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.cpp @@ -28,6 +28,9 @@ #include "RimWorkflowViewBinding.h" #include "RimWorkflowWellPathBinding.h" +#include "cafPdmUiOrdering.h" +#include "cafPdmUiTreeOrdering.h" + #include #include @@ -86,6 +89,20 @@ void RimWorkflowTaskInput::buildFromSchema( const QJsonArray& configFields ) } } +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"; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.h index ad75979398a..8a15fea8a15 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.h @@ -38,6 +38,10 @@ class RimWorkflowTaskInput : public caf::PdmObjectCollection m_taskName; }; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowViewBinding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowViewBinding.h index 40b6a6edffd..8c684dc56b8 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowViewBinding.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowViewBinding.h @@ -33,6 +33,8 @@ class RimWorkflowViewBinding : public RimWorkflowFieldBinding QString toYamlValue() const override; + caf::PdmFieldHandle* valueField() override { return &m_view; } + private: QList calculateValueOptions( const caf::PdmFieldHandle* fieldNeedingOptions ) override; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowWellPathBinding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowWellPathBinding.h index ab04ea4fabd..3f030eabd81 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowWellPathBinding.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowWellPathBinding.h @@ -33,6 +33,8 @@ class RimWorkflowWellPathBinding : public RimWorkflowFieldBinding QString toYamlValue() const override; + caf::PdmFieldHandle* valueField() override { return &m_wellPath; } + private: QList calculateValueOptions( const caf::PdmFieldHandle* fieldNeedingOptions ) override; From e2154ca09d99baa79ea1dd96339e7c1f6008674c Mon Sep 17 00:00:00 2001 From: Kristian Bendiksen Date: Mon, 18 May 2026 14:25:50 +0200 Subject: [PATCH 7/9] Workflows: Move Run from a push-button field to a context-menu item The Run action on a job is now exposed as RicRunWorkflowJobFeature in the workflow context menu, replacing the m_runButton field and its fieldChangedByUi handler. The job's property editor is left with just the name and per-task binding groups. --- .../WorkflowCommands/CMakeLists_files.cmake | 6 ++- .../RicRunWorkflowJobFeature.cpp | 45 +++++++++++++++++++ .../RicRunWorkflowJobFeature.h | 31 +++++++++++++ .../Workflow/RimWorkflowJob.cpp | 19 +++----- .../Workflow/RimWorkflowJob.h | 2 +- 5 files changed, 88 insertions(+), 15 deletions(-) create mode 100644 ApplicationLibCode/Commands/WorkflowCommands/RicRunWorkflowJobFeature.cpp create mode 100644 ApplicationLibCode/Commands/WorkflowCommands/RicRunWorkflowJobFeature.h diff --git a/ApplicationLibCode/Commands/WorkflowCommands/CMakeLists_files.cmake b/ApplicationLibCode/Commands/WorkflowCommands/CMakeLists_files.cmake index a2fbfb09e0e..fee641c54de 100644 --- a/ApplicationLibCode/Commands/WorkflowCommands/CMakeLists_files.cmake +++ b/ApplicationLibCode/Commands/WorkflowCommands/CMakeLists_files.cmake @@ -1,6 +1,8 @@ -set(SOURCE_GROUP_HEADER_FILES ${CMAKE_CURRENT_LIST_DIR}/RicNewWorkflowJobFeature.h) +set(SOURCE_GROUP_HEADER_FILES ${CMAKE_CURRENT_LIST_DIR}/RicNewWorkflowJobFeature.h + ${CMAKE_CURRENT_LIST_DIR}/RicRunWorkflowJobFeature.h) -set(SOURCE_GROUP_SOURCE_FILES ${CMAKE_CURRENT_LIST_DIR}/RicNewWorkflowJobFeature.cpp) +set(SOURCE_GROUP_SOURCE_FILES ${CMAKE_CURRENT_LIST_DIR}/RicNewWorkflowJobFeature.cpp + ${CMAKE_CURRENT_LIST_DIR}/RicRunWorkflowJobFeature.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/RicRunWorkflowJobFeature.cpp b/ApplicationLibCode/Commands/WorkflowCommands/RicRunWorkflowJobFeature.cpp new file mode 100644 index 00000000000..1fc0411dabe --- /dev/null +++ b/ApplicationLibCode/Commands/WorkflowCommands/RicRunWorkflowJobFeature.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 "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 +{ + return caf::selectedObjectsByType().size() == 1; +} 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/Workflow/RimWorkflowJob.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.cpp index 5da56199419..b9db8aedb6d 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.cpp @@ -27,9 +27,9 @@ #include "RiuMainWindow.h" #include "RiuWorkflowRunDialog.h" +#include "cafCmdFeatureMenuBuilder.h" #include "cafPdmUiGroup.h" #include "cafPdmUiOrdering.h" -#include "cafPdmUiPushButtonEditor.h" #include "cafPdmUiTreeOrdering.h" #include @@ -74,10 +74,6 @@ RimWorkflowJob::RimWorkflowJob() CAF_PDM_InitFieldNoDefault( &m_name, "Name", "Name" ); - CAF_PDM_InitField( &m_runButton, "RunButton", false, "Run" ); - m_runButton.uiCapability()->setUiEditorTypeName( caf::PdmUiPushButtonEditor::uiEditorTypeName() ); - m_runButton.xmlCapability()->disableIO(); - CAF_PDM_InitFieldNoDefault( &m_taskInputs, "TaskInputs", "" ); } @@ -95,7 +91,6 @@ void RimWorkflowJob::initAfterRead() void RimWorkflowJob::defineUiOrdering( QString uiConfigName, caf::PdmUiOrdering& uiOrdering ) { uiOrdering.add( &m_name ); - uiOrdering.add( &m_runButton ); for ( RimWorkflowTaskInput* task : m_taskInputs.childrenByType() ) { @@ -136,18 +131,18 @@ void RimWorkflowJob::setTaskInputs( std::vector inputs ) void RimWorkflowJob::fieldChangedByUi( const caf::PdmFieldHandle* changedField, const QVariant& oldValue, const QVariant& newValue ) { - if ( changedField == &m_runButton ) - { - m_runButton = false; - runJob(); - } - else if ( changedField == &m_name ) + if ( changedField == &m_name ) { setUiName( m_name() ); uiCapability()->updateConnectedEditors(); } } +void RimWorkflowJob::appendMenuItems( caf::CmdFeatureMenuBuilder& menuBuilder ) const +{ + menuBuilder << "RicRunWorkflowJobFeature"; +} + QString RimWorkflowJob::writeInputYaml( const QString& path ) const { QString body; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.h index 1171b701127..fd736832b90 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.h @@ -44,9 +44,9 @@ class RimWorkflowJob : public caf::PdmObject 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::PdmField m_runButton; caf::PdmChildArrayField m_taskInputs; }; From 337395364c0ef9069b71e24d6a4edcdfc817879e Mon Sep 17 00:00:00 2001 From: Kristian Bendiksen Date: Mon, 18 May 2026 14:54:54 +0200 Subject: [PATCH 8/9] Workflows: Stream job output to the Messages panel Replace RiuWorkflowRunDialog with a lightweight RiuWorkflowJobRunner QObject that owns the QProcess and forwards each stdout line as RiaLogging::info and stderr line as RiaLogging::warning, prefixed with "[ / ]". The lifecycle messages (Running / Finished / failed) go through the same logger. The runner deletes itself on QProcess::finished. RimWorkflowJob holds the runner via QPointer (transient, not serialized, safe for the XML-clone path used by New Job). Cancellation moves to RicCancelWorkflowJobFeature in the context menu; RicRunWorkflowJobFeature is disabled while a job is running so Run and Cancel toggle in place. --- .../WorkflowCommands/CMakeLists_files.cmake | 14 ++- .../RicCancelWorkflowJobFeature.cpp | 46 ++++++++ .../RicCancelWorkflowJobFeature.h | 31 +++++ .../RicRunWorkflowJobFeature.cpp | 3 +- .../Workflow/RimWorkflowJob.cpp | 28 ++++- .../Workflow/RimWorkflowJob.h | 7 ++ .../UserInterface/CMakeLists_files.cmake | 4 +- .../UserInterface/RiuWorkflowJobRunner.cpp | 104 +++++++++++++++++ ...flowRunDialog.h => RiuWorkflowJobRunner.h} | 28 ++--- .../UserInterface/RiuWorkflowRunDialog.cpp | 108 ------------------ 10 files changed, 239 insertions(+), 134 deletions(-) create mode 100644 ApplicationLibCode/Commands/WorkflowCommands/RicCancelWorkflowJobFeature.cpp create mode 100644 ApplicationLibCode/Commands/WorkflowCommands/RicCancelWorkflowJobFeature.h create mode 100644 ApplicationLibCode/UserInterface/RiuWorkflowJobRunner.cpp rename ApplicationLibCode/UserInterface/{RiuWorkflowRunDialog.h => RiuWorkflowJobRunner.h} (71%) delete mode 100644 ApplicationLibCode/UserInterface/RiuWorkflowRunDialog.cpp diff --git a/ApplicationLibCode/Commands/WorkflowCommands/CMakeLists_files.cmake b/ApplicationLibCode/Commands/WorkflowCommands/CMakeLists_files.cmake index fee641c54de..1a969ad9329 100644 --- a/ApplicationLibCode/Commands/WorkflowCommands/CMakeLists_files.cmake +++ b/ApplicationLibCode/Commands/WorkflowCommands/CMakeLists_files.cmake @@ -1,8 +1,14 @@ -set(SOURCE_GROUP_HEADER_FILES ${CMAKE_CURRENT_LIST_DIR}/RicNewWorkflowJobFeature.h - ${CMAKE_CURRENT_LIST_DIR}/RicRunWorkflowJobFeature.h) +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) +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/RicRunWorkflowJobFeature.cpp b/ApplicationLibCode/Commands/WorkflowCommands/RicRunWorkflowJobFeature.cpp index 1fc0411dabe..8da5a00c768 100644 --- a/ApplicationLibCode/Commands/WorkflowCommands/RicRunWorkflowJobFeature.cpp +++ b/ApplicationLibCode/Commands/WorkflowCommands/RicRunWorkflowJobFeature.cpp @@ -41,5 +41,6 @@ void RicRunWorkflowJobFeature::setupActionLook( QAction* actionToSetup ) bool RicRunWorkflowJobFeature::isCommandEnabled() const { - return caf::selectedObjectsByType().size() == 1; + auto jobs = caf::selectedObjectsByType(); + return jobs.size() == 1 && !jobs.front()->isRunning(); } diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.cpp index b9db8aedb6d..dee89998139 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.cpp @@ -25,7 +25,7 @@ #include "RiaPreferences.h" #include "RiuMainWindow.h" -#include "RiuWorkflowRunDialog.h" +#include "RiuWorkflowJobRunner.h" #include "cafCmdFeatureMenuBuilder.h" #include "cafPdmUiGroup.h" @@ -141,6 +141,17 @@ void RimWorkflowJob::fieldChangedByUi( const caf::PdmFieldHandle* changedField, 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 @@ -160,6 +171,12 @@ QString RimWorkflowJob::writeInputYaml( const QString& path ) const void RimWorkflowJob::runJob() { + if ( isRunning() ) + { + RiaLogging::warning( "Job is already running." ); + return; + } + auto* workflow = firstAncestorOrThisOfType(); if ( !workflow ) { @@ -200,10 +217,9 @@ void RimWorkflowJob::runJob() QStringList args{ "-m", "rips.taskmaestro_helper", "run", dir, "--input", inputPath, "--grpc-port", QString::number( port.value() ) }; - QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + const QString label = workflow->uiName() + " / " + m_name(); - auto* dialog = new RiuWorkflowRunDialog( workflow->uiName() + " / " + m_name(), RiuMainWindow::instance() ); - dialog->setAttribute( Qt::WA_DeleteOnClose ); - dialog->show(); - dialog->start( python, args, env ); + 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 index fd736832b90..326134aca7c 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.h @@ -24,6 +24,10 @@ #include "cafPdmField.h" #include "cafPdmObject.h" +#include + +class RiuWorkflowJobRunner; + class RimWorkflowJob : public caf::PdmObject { CAF_PDM_HEADER_INIT; @@ -38,6 +42,8 @@ class RimWorkflowJob : public caf::PdmObject 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; @@ -49,4 +55,5 @@ class RimWorkflowJob : public caf::PdmObject private: caf::PdmField m_name; caf::PdmChildArrayField m_taskInputs; + QPointer m_runner; }; diff --git a/ApplicationLibCode/UserInterface/CMakeLists_files.cmake b/ApplicationLibCode/UserInterface/CMakeLists_files.cmake index 1d235ce006e..ce96ad4d872 100644 --- a/ApplicationLibCode/UserInterface/CMakeLists_files.cmake +++ b/ApplicationLibCode/UserInterface/CMakeLists_files.cmake @@ -76,7 +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}/RiuWorkflowRunDialog.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 @@ -194,7 +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}/RiuWorkflowRunDialog.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/RiuWorkflowRunDialog.h b/ApplicationLibCode/UserInterface/RiuWorkflowJobRunner.h similarity index 71% rename from ApplicationLibCode/UserInterface/RiuWorkflowRunDialog.h rename to ApplicationLibCode/UserInterface/RiuWorkflowJobRunner.h index 48b651223cb..7b16c240d16 100644 --- a/ApplicationLibCode/UserInterface/RiuWorkflowRunDialog.h +++ b/ApplicationLibCode/UserInterface/RiuWorkflowJobRunner.h @@ -18,32 +18,34 @@ #pragma once -#include -#include +#include "RiaLogging.h" -class QPlainTextEdit; -class QPushButton; +#include +#include +#include +#include +#include -class RiuWorkflowRunDialog : public QDialog +class RiuWorkflowJobRunner : public QObject { Q_OBJECT public: - explicit RiuWorkflowRunDialog( const QString& workflowName, QWidget* parent = nullptr ); - ~RiuWorkflowRunDialog() override; + 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 ); - void onCancelClicked(); private: - void appendLog( const QString& text ); + void drainLines( QByteArray& buffer, RILogLevel level ); - QProcess m_process; - QPlainTextEdit* m_log; - QPushButton* m_cancelButton; - QPushButton* m_closeButton; + QString m_label; + QProcess m_process; + QByteArray m_stdoutBuf; + QByteArray m_stderrBuf; }; diff --git a/ApplicationLibCode/UserInterface/RiuWorkflowRunDialog.cpp b/ApplicationLibCode/UserInterface/RiuWorkflowRunDialog.cpp deleted file mode 100644 index e4e3c4bea47..00000000000 --- a/ApplicationLibCode/UserInterface/RiuWorkflowRunDialog.cpp +++ /dev/null @@ -1,108 +0,0 @@ -///////////////////////////////////////////////////////////////////////////////// -// -// 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 "RiuWorkflowRunDialog.h" - -#include -#include -#include -#include -#include - -RiuWorkflowRunDialog::RiuWorkflowRunDialog( const QString& workflowName, QWidget* parent ) - : QDialog( parent ) -{ - setWindowTitle( QString( "Run Workflow: %1" ).arg( workflowName ) ); - resize( 720, 480 ); - setModal( false ); - - m_log = new QPlainTextEdit( this ); - m_log->setReadOnly( true ); - m_log->setFont( QFontDatabase::systemFont( QFontDatabase::FixedFont ) ); - - m_cancelButton = new QPushButton( "Cancel", this ); - m_closeButton = new QPushButton( "Close", this ); - m_closeButton->setEnabled( false ); - - auto* buttons = new QHBoxLayout; - buttons->addStretch(); - buttons->addWidget( m_cancelButton ); - buttons->addWidget( m_closeButton ); - - auto* layout = new QVBoxLayout( this ); - layout->addWidget( m_log ); - layout->addLayout( buttons ); - - connect( m_cancelButton, &QPushButton::clicked, this, &RiuWorkflowRunDialog::onCancelClicked ); - connect( m_closeButton, &QPushButton::clicked, this, &QDialog::accept ); - - connect( &m_process, &QProcess::readyReadStandardOutput, this, &RiuWorkflowRunDialog::onReadyReadStdout ); - connect( &m_process, &QProcess::readyReadStandardError, this, &RiuWorkflowRunDialog::onReadyReadStderr ); - connect( &m_process, QOverload::of( &QProcess::finished ), this, &RiuWorkflowRunDialog::onProcessFinished ); -} - -RiuWorkflowRunDialog::~RiuWorkflowRunDialog() -{ - if ( m_process.state() != QProcess::NotRunning ) - { - m_process.kill(); - m_process.waitForFinished( 2000 ); - } -} - -void RiuWorkflowRunDialog::start( const QString& program, const QStringList& arguments, const QProcessEnvironment& env ) -{ - appendLog( QString( "$ %1 %2" ).arg( program, arguments.join( ' ' ) ) ); - m_process.setProcessEnvironment( env ); - m_process.start( program, arguments ); -} - -void RiuWorkflowRunDialog::onReadyReadStdout() -{ - appendLog( QString::fromUtf8( m_process.readAllStandardOutput() ) ); -} - -void RiuWorkflowRunDialog::onReadyReadStderr() -{ - appendLog( QString::fromUtf8( m_process.readAllStandardError() ) ); -} - -void RiuWorkflowRunDialog::onProcessFinished( int exitCode, QProcess::ExitStatus status ) -{ - QString tail = ( status == QProcess::NormalExit ) ? QString( "[exited with code %1]" ).arg( exitCode ) : QString( "[crashed]" ); - appendLog( "\n" + tail ); - m_cancelButton->setEnabled( false ); - m_closeButton->setEnabled( true ); -} - -void RiuWorkflowRunDialog::onCancelClicked() -{ - if ( m_process.state() != QProcess::NotRunning ) - { - m_process.terminate(); - if ( !m_process.waitForFinished( 2000 ) ) m_process.kill(); - } -} - -void RiuWorkflowRunDialog::appendLog( const QString& text ) -{ - if ( text.isEmpty() ) return; - m_log->moveCursor( QTextCursor::End ); - m_log->insertPlainText( text ); - m_log->moveCursor( QTextCursor::End ); -} From fe9046da4ef574bfd8221d3f5b1b181874a423e9 Mon Sep 17 00:00:00 2001 From: Kristian Bendiksen Date: Mon, 18 May 2026 19:07:18 +0200 Subject: [PATCH 9/9] Workflows: Deduplicate findPythonExecutable helper Promote the helper to a static method on RimWorkflow and remove the identical copy from RimWorkflowJob.cpp. --- .../ProjectDataModel/Workflow/RimWorkflow.cpp | 53 +++++++++---------- .../ProjectDataModel/Workflow/RimWorkflow.h | 2 + .../Workflow/RimWorkflowJob.cpp | 32 +---------- 3 files changed, 28 insertions(+), 59 deletions(-) diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.cpp index cf6cec2e5ab..935cd04a92d 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.cpp @@ -37,33 +37,6 @@ CAF_PDM_SOURCE_INIT( RimWorkflow, "Workflow" ); -namespace -{ -QString 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 {}; -} -} // namespace - RimWorkflow::RimWorkflow() { CAF_PDM_InitObject( "Workflow", ":/Folder.png" ); @@ -130,7 +103,7 @@ bool RimWorkflow::loadFromDirectory( QString* errorMessage ) const QString dir = workflowDirectory(); if ( dir.isEmpty() ) return false; - QString python = findPythonExecutable(); + QString python = RimWorkflow::findPythonExecutable(); if ( python.isEmpty() ) { m_loadError = "No usable Python interpreter found"; @@ -205,3 +178,27 @@ bool RimWorkflow::loadFromDirectory( QString* errorMessage ) 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 index 61eaa1ae745..55d15f78d09 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.h @@ -41,6 +41,8 @@ class RimWorkflow : public caf::PdmObject std::vector jobs() const; void addJob( RimWorkflowJob* job ); + static QString findPythonExecutable(); + protected: void appendMenuItems( caf::CmdFeatureMenuBuilder& menuBuilder ) const override; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.cpp index dee89998139..afe6e5cafe9 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.cpp @@ -22,7 +22,6 @@ #include "RiaApplication.h" #include "RiaLogging.h" -#include "RiaPreferences.h" #include "RiuMainWindow.h" #include "RiuWorkflowJobRunner.h" @@ -34,40 +33,11 @@ #include #include -#include #include -#include #include CAF_PDM_SOURCE_INIT( RimWorkflowJob, "WorkflowJob" ); -namespace -{ -QString 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 {}; -} -} // namespace - RimWorkflowJob::RimWorkflowJob() { CAF_PDM_InitObject( "Job", ":/Bullet.png" ); @@ -194,7 +164,7 @@ void RimWorkflowJob::runJob() return; } - QString python = findPythonExecutable(); + QString python = RimWorkflow::findPythonExecutable(); if ( python.isEmpty() ) { RiaLogging::warning( "Cannot run workflow: no Python interpreter found." );