diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/CMakeLists_files.cmake b/ApplicationLibCode/ProjectDataModel/Workflow/CMakeLists_files.cmake index 42a9f7bc11..171dea07e8 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/CMakeLists_files.cmake +++ b/ApplicationLibCode/ProjectDataModel/Workflow/CMakeLists_files.cmake @@ -4,6 +4,7 @@ set(SOURCE_GROUP_HEADER_FILES ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowJob.h ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowTaskInput.h ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowFieldBinding.h + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowArrayBinding.h ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowStringBinding.h ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowFloatBinding.h ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowIntBinding.h @@ -21,6 +22,7 @@ set(SOURCE_GROUP_SOURCE_FILES ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowJob.cpp ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowTaskInput.cpp ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowFieldBinding.cpp + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowArrayBinding.cpp ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowStringBinding.cpp ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowFloatBinding.cpp ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowIntBinding.cpp diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.cpp index 935cd04a92..efb0b0ae63 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.cpp @@ -74,6 +74,16 @@ QString RimWorkflow::workflowDirectory() const return m_workflowDirectory().path(); } +QJsonObject RimWorkflow::graph() const +{ + return m_graph; +} + +QString RimWorkflow::loadError() const +{ + return m_loadError(); +} + std::vector RimWorkflow::jobs() const { std::vector result; @@ -98,6 +108,7 @@ void RimWorkflow::appendMenuItems( caf::CmdFeatureMenuBuilder& menuBuilder ) con bool RimWorkflow::loadFromDirectory( QString* errorMessage ) { m_jobs.deleteChildren(); + m_graph = {}; m_loadError = ""; const QString dir = workflowDirectory(); @@ -154,6 +165,13 @@ bool RimWorkflow::loadFromDirectory( QString* errorMessage ) } const QJsonObject root = doc.object(); + if ( !root.value( "tasks" ).isArray() || !root.value( "edges" ).isArray() ) + { + m_loadError = "Invalid workflow graph schema"; + if ( errorMessage ) *errorMessage = m_loadError; + return false; + } + m_graph = root; if ( root.contains( "name" ) ) m_name = root.value( "name" ).toString(); if ( root.contains( "description" ) ) m_description = root.value( "description" ).toString(); setUiName( m_name() ); @@ -162,7 +180,8 @@ bool RimWorkflow::loadFromDirectory( QString* errorMessage ) for ( const QJsonValue& tv : root.value( "tasks" ).toArray() ) { const QJsonObject taskObj = tv.toObject(); - auto* input = new RimWorkflowTaskInput; + if ( taskObj.value( "config_fields" ).toArray().isEmpty() ) continue; + auto* input = new RimWorkflowTaskInput; input->setTaskName( taskObj.value( "name" ).toString() ); input->buildFromSchema( taskObj.value( "config_fields" ).toArray() ); taskInputs.push_back( input ); diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.h index 55d15f78d0..17b8465cad 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.h @@ -23,6 +23,8 @@ #include "cafPdmField.h" #include "cafPdmObject.h" +#include + class RimWorkflowJob; class RimWorkflow : public caf::PdmObject @@ -32,9 +34,11 @@ class RimWorkflow : public caf::PdmObject public: RimWorkflow(); - QString name() const; - void setWorkflowDirectory( const QString& directory ); - QString workflowDirectory() const; + QString name() const; + void setWorkflowDirectory( const QString& directory ); + QString workflowDirectory() const; + QJsonObject graph() const; + QString loadError() const; bool loadFromDirectory( QString* errorMessage = nullptr ); @@ -52,4 +56,5 @@ private: caf::PdmField m_workflowDirectory; caf::PdmField m_loadError; caf::PdmChildArrayField m_jobs; + QJsonObject m_graph; }; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowArrayBinding.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowArrayBinding.cpp new file mode 100644 index 0000000000..a9b4c2a186 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowArrayBinding.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 "RimWorkflowArrayBinding.h" + +#include +#include +#include + +CAF_PDM_SOURCE_INIT( RimWorkflowArrayBinding, "WorkflowArrayBinding" ); + +RimWorkflowArrayBinding::RimWorkflowArrayBinding() +{ + CAF_PDM_InitFieldNoDefault( &m_value, "Value", "Value" ); +} + +void RimWorkflowArrayBinding::applySchema( const QJsonObject& fieldSchema ) +{ + RimWorkflowFieldBinding::applySchema( fieldSchema ); + if ( fieldSchema.value( "default" ).isArray() ) + { + m_value = QString::fromUtf8( QJsonDocument( fieldSchema.value( "default" ).toArray() ).toJson( QJsonDocument::Compact ) ); + } +} + +QString RimWorkflowArrayBinding::toYamlValue() const +{ + return hasValue() ? m_value() : "null"; +} diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowArrayBinding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowArrayBinding.h new file mode 100644 index 0000000000..44b025d3f6 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowArrayBinding.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 RimWorkflowArrayBinding : public RimWorkflowFieldBinding +{ + CAF_PDM_HEADER_INIT; + +public: + RimWorkflowArrayBinding(); + + 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/RimWorkflowBoolBinding.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowBoolBinding.cpp index ecd0a4ec06..000eb6fa64 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowBoolBinding.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowBoolBinding.cpp @@ -38,5 +38,6 @@ void RimWorkflowBoolBinding::applySchema( const QJsonObject& fieldSchema ) QString RimWorkflowBoolBinding::toYamlValue() const { + if ( !hasValue() ) return "null"; return m_value() ? "true" : "false"; } diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCaseBinding.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCaseBinding.cpp index 88ac22376a..23ae4e49ef 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCaseBinding.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCaseBinding.cpp @@ -28,6 +28,11 @@ RimWorkflowCaseBinding::RimWorkflowCaseBinding() CAF_PDM_InitFieldNoDefault( &m_case, "Case", "Case" ); } +QString RimWorkflowCaseBinding::displayValue() const +{ + return m_case() ? m_case()->caseUserDescription() : "(not selected)"; +} + QString RimWorkflowCaseBinding::toYamlValue() const { if ( m_case() == nullptr ) return "null"; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCaseBinding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCaseBinding.h index 18efaa096f..78ad861785 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCaseBinding.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCaseBinding.h @@ -32,6 +32,7 @@ public: RimWorkflowCaseBinding(); QString toYamlValue() const override; + QString displayValue() const override; caf::PdmFieldHandle* valueField() override { return &m_case; } diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDateBinding.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDateBinding.cpp index 0985bdf65d..78159ca33d 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDateBinding.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDateBinding.cpp @@ -39,5 +39,5 @@ void RimWorkflowDateBinding::applySchema( const QJsonObject& fieldSchema ) QString RimWorkflowDateBinding::toYamlValue() const { - return QString( "\"%1\"" ).arg( m_value().toString( Qt::ISODate ) ); + return hasValue() ? yamlQuotedScalar( m_value().toString( Qt::ISODate ) ) : "null"; } diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFieldBinding.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFieldBinding.cpp index 1ab79ac0ff..af0f711fc3 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFieldBinding.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFieldBinding.cpp @@ -18,6 +18,13 @@ #include "RimWorkflowFieldBinding.h" +#include "RiuMainWindow.h" + +#include "cafPdmUiFieldHandle.h" + +#include +#include +#include #include CAF_PDM_ABSTRACT_SOURCE_INIT( RimWorkflowFieldBinding, "WorkflowFieldBinding" ); @@ -34,6 +41,9 @@ RimWorkflowFieldBinding::RimWorkflowFieldBinding() CAF_PDM_InitField( &m_required, "Required", false, "Required" ); m_required.uiCapability()->setUiReadOnly( true ); + + CAF_PDM_InitField( &m_hasValue, "HasValue", false, "Has Value" ); + m_hasValue.uiCapability()->setUiHidden( true ); } QString RimWorkflowFieldBinding::fieldName() const @@ -57,11 +67,45 @@ void RimWorkflowFieldBinding::setRequired( bool required ) m_required = required; } +QString RimWorkflowFieldBinding::displayValue() const +{ + if ( !hasValue() ) return "(not set)"; + + auto* field = const_cast( this )->valueField(); + if ( !field || !field->uiCapability() ) return {}; + + const QVariant value = field->uiCapability()->uiValue(); + if ( value.metaType().id() == QMetaType::QDate ) return value.toDate().toString( Qt::ISODate ); + const QString text = value.toString(); + return text.isEmpty() ? "(not set)" : text; +} + +void RimWorkflowFieldBinding::fieldChangedByUi( const caf::PdmFieldHandle* changedField, const QVariant& oldValue, const QVariant& newValue ) +{ + if ( changedField == valueField() ) + { + m_hasValue = true; + if ( auto* mainWindow = RiuMainWindow::instance() ) mainWindow->workflowBindingChanged( this ); + } +} + +bool RimWorkflowFieldBinding::hasValue() const +{ + return m_hasValue(); +} + +QString RimWorkflowFieldBinding::yamlQuotedScalar( const QString& value ) +{ + const QByteArray encoded = QJsonDocument( QJsonArray{ value } ).toJson( QJsonDocument::Compact ); + return QString::fromUtf8( encoded.mid( 1, encoded.size() - 2 ) ); +} + void RimWorkflowFieldBinding::applySchema( const QJsonObject& fieldSchema ) { setFieldName( fieldSchema.value( "name" ).toString() ); setDescription( fieldSchema.value( "description" ).toString() ); setRequired( fieldSchema.value( "required" ).toBool( false ) ); + m_hasValue = fieldSchema.contains( "default" ) && !fieldSchema.value( "default" ).isNull(); if ( auto* vf = valueField() ) { diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFieldBinding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFieldBinding.h index 04c412bcf0..c4b3e9b5bd 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFieldBinding.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFieldBinding.h @@ -37,11 +37,18 @@ public: virtual void applySchema( const QJsonObject& fieldSchema ); virtual QString toYamlValue() const = 0; + virtual QString displayValue() const; virtual caf::PdmFieldHandle* valueField() = 0; protected: + void fieldChangedByUi( const caf::PdmFieldHandle* changedField, const QVariant& oldValue, const QVariant& newValue ) override; + + bool hasValue() const; + static QString yamlQuotedScalar( const QString& value ); + caf::PdmField m_fieldName; caf::PdmField m_description; caf::PdmField m_required; + caf::PdmField m_hasValue; }; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFilePathBinding.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFilePathBinding.cpp index 47dc86b1e2..b502628130 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFilePathBinding.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFilePathBinding.cpp @@ -44,12 +44,17 @@ void RimWorkflowFilePathBinding::applySchema( const QJsonObject& fieldSchema ) } } +QString RimWorkflowFilePathBinding::displayValue() const +{ + if ( !hasValue() ) return "(not set)"; + + const QString path = m_value().path(); + return path.isEmpty() ? "(not set)" : path; +} + QString RimWorkflowFilePathBinding::toYamlValue() const { - QString p = m_value().path(); - p.replace( "\\", "\\\\" ); - p.replace( "\"", "\\\"" ); - return QString( "\"%1\"" ).arg( p ); + return hasValue() ? yamlQuotedScalar( m_value().path() ) : "null"; } void RimWorkflowFilePathBinding::defineEditorAttribute( const caf::PdmFieldHandle* field, QString uiConfigName, caf::PdmUiEditorAttribute* attribute ) diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFilePathBinding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFilePathBinding.h index 60ae3e8753..8629a305bb 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFilePathBinding.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFilePathBinding.h @@ -31,6 +31,7 @@ public: void applySchema( const QJsonObject& fieldSchema ) override; QString toYamlValue() const override; + QString displayValue() const override; caf::PdmFieldHandle* valueField() override { return &m_value; } diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFloatBinding.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFloatBinding.cpp index 7777f94933..6e19777aeb 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFloatBinding.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFloatBinding.cpp @@ -38,5 +38,5 @@ void RimWorkflowFloatBinding::applySchema( const QJsonObject& fieldSchema ) QString RimWorkflowFloatBinding::toYamlValue() const { - return QString::number( m_value(), 'g', 17 ); + return hasValue() ? QString::number( m_value(), 'g', 17 ) : "null"; } diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowIntBinding.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowIntBinding.cpp index 28992ad1e7..8734d36b5b 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowIntBinding.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowIntBinding.cpp @@ -38,5 +38,5 @@ void RimWorkflowIntBinding::applySchema( const QJsonObject& fieldSchema ) QString RimWorkflowIntBinding::toYamlValue() const { - return QString::number( m_value() ); + return hasValue() ? QString::number( m_value() ) : "null"; } diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.cpp index afe6e5cafe..be21077ddc 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.cpp @@ -27,12 +27,15 @@ #include "RiuWorkflowJobRunner.h" #include "cafCmdFeatureMenuBuilder.h" +#include "cafPdmPointer.h" #include "cafPdmUiGroup.h" #include "cafPdmUiOrdering.h" #include "cafPdmUiTreeOrdering.h" #include #include +#include +#include #include #include @@ -47,6 +50,11 @@ RimWorkflowJob::RimWorkflowJob() CAF_PDM_InitFieldNoDefault( &m_taskInputs, "TaskInputs", "" ); } +RimWorkflowJob::~RimWorkflowJob() +{ + cancelJob(); +} + void RimWorkflowJob::setJobName( const QString& name ) { m_name = name; @@ -119,6 +127,64 @@ bool RimWorkflowJob::isRunning() const return m_runner && m_runner->isRunning(); } +QMap RimWorkflowJob::taskStates() const +{ + return m_taskStates; +} + +QMap RimWorkflowJob::taskErrors() const +{ + return m_taskErrors; +} + +QString RimWorkflowJob::runStatus() const +{ + return m_runStatus; +} + +void RimWorkflowJob::updateTaskState( const QString& runId, const QString& taskName, const QString& state, const QString& error ) +{ + if ( runId != m_runId || ( state != "running" && state != "completed" && state != "failed" ) ) return; + auto* workflow = firstAncestorOrThisOfType(); + if ( !workflow ) return; + bool knownTask = false; + for ( const QJsonValue& value : workflow->graph().value( "tasks" ).toArray() ) + { + if ( value.toObject().value( "name" ).toString() == taskName ) knownTask = true; + } + if ( !knownTask ) return; + + m_taskStates[taskName] = state; + if ( state == "running" ) m_activeTask = taskName; + if ( state == "failed" ) + { + m_taskErrors[taskName] = error; + m_runStatus = "Failed: " + error; + } + if ( auto* window = RiuMainWindow::instance() ) window->workflowJobStateChanged( this ); +} + +void RimWorkflowJob::finishRun( const QString& runId, bool succeeded, bool cancelled ) +{ + if ( runId != m_runId ) return; + if ( !succeeded ) + { + if ( m_taskStates.value( m_activeTask ) == "running" ) m_taskStates[m_activeTask] = "interrupted"; + for ( auto it = m_taskStates.begin(); it != m_taskStates.end(); ++it ) + { + if ( it.value() == "pending" ) it.value() = "skipped"; + } + } + if ( cancelled ) + m_runStatus = "Cancelled"; + else if ( succeeded ) + m_runStatus = "Completed"; + else if ( !m_runStatus.startsWith( "Failed:" ) ) + m_runStatus = "Failed (see workflow log)"; + m_activeTask.clear(); + if ( auto* window = RiuMainWindow::instance() ) window->workflowJobStateChanged( this ); +} + void RimWorkflowJob::cancelJob() { if ( m_runner ) m_runner->cancel(); @@ -185,11 +251,39 @@ void RimWorkflowJob::runJob() return; } - QStringList args{ "-m", "rips.taskmaestro_helper", "run", dir, "--input", inputPath, "--grpc-port", QString::number( port.value() ) }; + m_runId = QUuid::createUuid().toString( QUuid::WithoutBraces ); + m_taskStates.clear(); + m_taskErrors.clear(); + for ( const QJsonValue& value : workflow->graph().value( "tasks" ).toArray() ) + { + const QString taskName = value.toObject().value( "name" ).toString(); + if ( !taskName.isEmpty() ) m_taskStates.insert( taskName, "pending" ); + } + m_activeTask.clear(); + m_runStatus = "Running"; + if ( auto* window = RiuMainWindow::instance() ) window->workflowJobStateChanged( this ); + + QStringList args{ "-m", "rips.taskmaestro_helper", "run", dir, "--input", inputPath, "--grpc-port", QString::number( port.value() ), "--run-id", m_runId }; QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); const QString label = workflow->uiName() + " / " + m_name(); - m_runner = new RiuWorkflowJobRunner( label, RiuMainWindow::instance() ); + m_runner = new RiuWorkflowJobRunner( label, RiuMainWindow::instance() ); + const QString runId = m_runId; + const caf::PdmPointer safeJob( this ); + QObject::connect( m_runner, + &RiuWorkflowJobRunner::taskStateChanged, + m_runner, + [safeJob, runId]( const QString& eventRunId, const QString& taskName, const QString& state, const QString& error ) + { + if ( safeJob && eventRunId == runId ) safeJob->updateTaskState( runId, taskName, state, error ); + } ); + QObject::connect( m_runner, + &RiuWorkflowJobRunner::runFinished, + m_runner, + [safeJob, runId]( bool succeeded, bool cancelled ) + { + if ( safeJob ) safeJob->finishRun( runId, succeeded, cancelled ); + } ); m_runner->start( python, args, env ); } diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.h index 326134aca7..68ced071c0 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.h @@ -24,6 +24,7 @@ #include "cafPdmField.h" #include "cafPdmObject.h" +#include #include class RiuWorkflowJobRunner; @@ -34,6 +35,7 @@ class RimWorkflowJob : public caf::PdmObject public: RimWorkflowJob(); + ~RimWorkflowJob() override; void setJobName( const QString& name ); @@ -45,6 +47,12 @@ public: void cancelJob(); bool isRunning() const; + QMap taskStates() const; + QMap taskErrors() const; + QString runStatus() const; + void updateTaskState( const QString& runId, const QString& taskName, const QString& state, const QString& error ); + void finishRun( const QString& runId, bool succeeded, bool cancelled ); + protected: void fieldChangedByUi( const caf::PdmFieldHandle* changedField, const QVariant& oldValue, const QVariant& newValue ) override; void initAfterRead() override; @@ -56,4 +64,9 @@ private: caf::PdmField m_name; caf::PdmChildArrayField m_taskInputs; QPointer m_runner; + QMap m_taskStates; + QMap m_taskErrors; + QString m_runStatus; + QString m_activeTask; + QString m_runId; }; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowStringBinding.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowStringBinding.cpp index 69b8c05dbb..53ec223ecb 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowStringBinding.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowStringBinding.cpp @@ -38,8 +38,5 @@ void RimWorkflowStringBinding::applySchema( const QJsonObject& fieldSchema ) QString RimWorkflowStringBinding::toYamlValue() const { - QString v = m_value(); - v.replace( "\\", "\\\\" ); - v.replace( "\"", "\\\"" ); - return QString( "\"%1\"" ).arg( v ); + return hasValue() ? yamlQuotedScalar( m_value() ) : "null"; } diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.cpp index fe34b256fd..597d4ab795 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.cpp @@ -18,6 +18,7 @@ #include "RimWorkflowTaskInput.h" +#include "RimWorkflowArrayBinding.h" #include "RimWorkflowBoolBinding.h" #include "RimWorkflowCaseBinding.h" #include "RimWorkflowDateBinding.h" @@ -53,6 +54,7 @@ RimWorkflowFieldBinding* createBinding( const QJsonObject& schema ) if ( type == "boolean" ) return new RimWorkflowBoolBinding; if ( type == "integer" ) return new RimWorkflowIntBinding; if ( type == "number" ) return new RimWorkflowFloatBinding; + if ( type == "array" ) return new RimWorkflowArrayBinding; return new RimWorkflowStringBinding; } } // namespace diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowViewBinding.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowViewBinding.cpp index 7d04233b57..193d1201ff 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowViewBinding.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowViewBinding.cpp @@ -30,6 +30,11 @@ RimWorkflowViewBinding::RimWorkflowViewBinding() CAF_PDM_InitFieldNoDefault( &m_view, "View", "View" ); } +QString RimWorkflowViewBinding::displayValue() const +{ + return m_view() ? m_view()->name() : "(not selected)"; +} + QString RimWorkflowViewBinding::toYamlValue() const { if ( m_view() == nullptr ) return "null"; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowViewBinding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowViewBinding.h index 8c684dc56b..b10b07eea6 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowViewBinding.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowViewBinding.h @@ -32,6 +32,7 @@ public: RimWorkflowViewBinding(); QString toYamlValue() const override; + QString displayValue() const override; caf::PdmFieldHandle* valueField() override { return &m_view; } diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowWellPathBinding.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowWellPathBinding.cpp index 2be242e024..ccacf0a723 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowWellPathBinding.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowWellPathBinding.cpp @@ -28,12 +28,16 @@ RimWorkflowWellPathBinding::RimWorkflowWellPathBinding() CAF_PDM_InitFieldNoDefault( &m_wellPath, "WellPath", "Well Path" ); } +QString RimWorkflowWellPathBinding::displayValue() const +{ + return m_wellPath() ? m_wellPath()->name() : "(not selected)"; +} + 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 ); + const QString name = yamlQuotedScalar( m_wellPath()->name() ); + return QString( "{__resinsight_ref__: WellPath, well_path_name: %1}" ).arg( name ); } QList RimWorkflowWellPathBinding::calculateValueOptions( const caf::PdmFieldHandle* fieldNeedingOptions ) diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowWellPathBinding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowWellPathBinding.h index 3f030eabd8..7ed96f07f6 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowWellPathBinding.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowWellPathBinding.h @@ -32,6 +32,7 @@ public: RimWorkflowWellPathBinding(); QString toYamlValue() const override; + QString displayValue() const override; caf::PdmFieldHandle* valueField() override { return &m_wellPath; } diff --git a/ApplicationLibCode/UserInterface/CMakeLists_files.cmake b/ApplicationLibCode/UserInterface/CMakeLists_files.cmake index e91b3d0b16..5ded3c5d28 100644 --- a/ApplicationLibCode/UserInterface/CMakeLists_files.cmake +++ b/ApplicationLibCode/UserInterface/CMakeLists_files.cmake @@ -77,6 +77,7 @@ set(SOURCE_GROUP_HEADER_FILES ${CMAKE_CURRENT_LIST_DIR}/RiuMessagePanel.h ${CMAKE_CURRENT_LIST_DIR}/RiuMessageDialog.h ${CMAKE_CURRENT_LIST_DIR}/RiuWorkflowJobRunner.h + ${CMAKE_CURRENT_LIST_DIR}/RiuWorkflowGraphView.h ${CMAKE_CURRENT_LIST_DIR}/RiuPlotObjectPicker.h ${CMAKE_CURRENT_LIST_DIR}/RiuContextMenuLauncher.h ${CMAKE_CURRENT_LIST_DIR}/RiuSummaryCurveDefinitionKeywords.h @@ -194,6 +195,7 @@ set(SOURCE_GROUP_SOURCE_FILES ${CMAKE_CURRENT_LIST_DIR}/RiuMessagePanel.cpp ${CMAKE_CURRENT_LIST_DIR}/RiuMessageDialog.cpp ${CMAKE_CURRENT_LIST_DIR}/RiuWorkflowJobRunner.cpp + ${CMAKE_CURRENT_LIST_DIR}/RiuWorkflowGraphView.cpp ${CMAKE_CURRENT_LIST_DIR}/RiuPlotObjectPicker.cpp ${CMAKE_CURRENT_LIST_DIR}/RiuContextMenuLauncher.cpp ${CMAKE_CURRENT_LIST_DIR}/RiuSummaryVectorSelectionUi.cpp diff --git a/ApplicationLibCode/UserInterface/RiuMainWindow.cpp b/ApplicationLibCode/UserInterface/RiuMainWindow.cpp index 421368204c..2afd0199e8 100644 --- a/ApplicationLibCode/UserInterface/RiuMainWindow.cpp +++ b/ApplicationLibCode/UserInterface/RiuMainWindow.cpp @@ -51,6 +51,10 @@ #include "RimViewLinker.h" #include "RimViewLinkerCollection.h" #include "RimViewWindow.h" +#include "Workflow/RimWorkflow.h" +#include "Workflow/RimWorkflowFieldBinding.h" +#include "Workflow/RimWorkflowJob.h" +#include "Workflow/RimWorkflowTaskInput.h" #include "RiuCellSelectionTool.h" #include "RiuDepthQwtPlot.h" @@ -70,6 +74,7 @@ #include "RiuTools.h" #include "RiuTreeViewEventFilter.h" #include "RiuViewer.h" +#include "RiuWorkflowGraphView.h" #include "cafAnimationToolBar.h" #include "cafCmdExecCommandManager.h" @@ -1282,6 +1287,11 @@ std::vector RiuMainWindow::viewWindows() void RiuMainWindow::setPdmRoot( caf::PdmObject* pdmRoot ) { m_pdmRoot = pdmRoot; + if ( !pdmRoot ) + { + m_displayedWorkflowJob = nullptr; + if ( m_workflowGraphView ) m_workflowGraphView->showGraph( {}, {} ); + } for ( auto tv : projectTreeViews() ) { @@ -1537,6 +1547,19 @@ void RiuMainWindow::selectedObjectsChanged() m_pdmUiPropertyView->showProperties( firstSelectedObject ); m_seismicHistogramPanel->showHistogram( firstSelectedObject ); + if ( uiItems.size() == 1 ) + { + if ( auto* workflow = dynamic_cast( firstSelectedObject ) ) + { + const auto jobs = workflow->jobs(); + showWorkflowGraph( workflow, jobs.empty() ? nullptr : jobs.front() ); + } + else if ( auto* job = dynamic_cast( firstSelectedObject ) ) + { + if ( auto* workflow = job->firstAncestorOrThisOfType() ) showWorkflowGraph( workflow, job ); + } + } + if ( uiItems.size() == 1 && m_allowActiveViewChangeFromSelection ) { // Find the reservoir view or the Plot that the selected item is within @@ -1594,6 +1617,58 @@ void RiuMainWindow::selectedObjectsChanged() //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- +void RiuMainWindow::workflowBindingChanged( const RimWorkflowFieldBinding* binding ) +{ + if ( !m_workflowGraphView || !m_displayedWorkflowJob || binding->firstAncestorOrThisOfType() != m_displayedWorkflowJob.p() ) + return; + + if ( auto* task = binding->firstAncestorOrThisOfType() ) + { + m_workflowGraphView->setTaskInputValue( task->taskName(), binding->fieldName(), binding->displayValue() ); + } +} + +void RiuMainWindow::workflowJobStateChanged( const RimWorkflowJob* job ) +{ + if ( !m_workflowGraphView || m_displayedWorkflowJob.p() != job ) return; + m_workflowGraphView->resetTaskStates(); + const auto states = job->taskStates(); + const auto errors = job->taskErrors(); + for ( auto it = states.cbegin(); it != states.cend(); ++it ) + m_workflowGraphView->setTaskState( it.key(), it.value(), errors.value( it.key() ) ); + m_workflowGraphView->setRunStatus( job->runStatus() ); +} + +void RiuMainWindow::showWorkflowGraph( RimWorkflow* workflow, RimWorkflowJob* job ) +{ + if ( !m_workflowGraphDock ) + { + m_workflowGraphDock = RiuDockWidgetTools::createDockWidget( "Workflow", "WorkflowGraph", dockManager() ); + m_workflowGraphView = new RiuWorkflowGraphView( m_workflowGraphDock ); + m_workflowGraphDock->setWidget( m_workflowGraphView ); + dockManager()->addDockWidget( ads::DockWidgetArea::CenterDockWidgetArea, + m_workflowGraphDock, + dockManager()->centralWidget()->dockAreaWidget() ); + } + + m_displayedWorkflowJob = job; + m_workflowGraphDock->setWindowTitle( QString( "Workflow: %1" ).arg( workflow->name() ) ); + m_workflowGraphView->showGraph( workflow->graph(), workflow->loadError() ); + if ( job ) + { + for ( RimWorkflowTaskInput* task : job->taskInputs() ) + { + for ( RimWorkflowFieldBinding* binding : task->items() ) + { + if ( binding ) m_workflowGraphView->setTaskInputValue( task->taskName(), binding->fieldName(), binding->displayValue() ); + } + } + } + if ( job ) workflowJobStateChanged( job ); + m_workflowGraphDock->toggleView( true ); + m_workflowGraphDock->setAsCurrentTab(); +} + void RiuMainWindow::slotNewObjectPropertyView() { ads::CDockWidget* dockWidget = diff --git a/ApplicationLibCode/UserInterface/RiuMainWindow.h b/ApplicationLibCode/UserInterface/RiuMainWindow.h index a89d90bb1c..a0db85745e 100644 --- a/ApplicationLibCode/UserInterface/RiuMainWindow.h +++ b/ApplicationLibCode/UserInterface/RiuMainWindow.h @@ -23,6 +23,7 @@ #include "RiuMainWindowBase.h" #include "cafPdmObjectHandle.h" +#include "cafPdmPointer.h" #include #include @@ -42,6 +43,9 @@ class QSlider; class Rim3dView; class RimCase; class RimViewWindow; +class RimWorkflow; +class RimWorkflowJob; +class RimWorkflowFieldBinding; class RiuMessagePanel; class RiuProcessMonitor; @@ -53,6 +57,7 @@ class RiuPvtPlotPanel; class RiuMohrsCirclePlot; class RiuSeismicHistogramPanel; class RiuCellSelectionTool; +class RiuWorkflowGraphView; class RicGridCalculatorDialog; @@ -126,6 +131,9 @@ public: RicGridCalculatorDialog* gridCalculatorDialog( bool createIfNotPresent ); + void workflowBindingChanged( const RimWorkflowFieldBinding* binding ); + void workflowJobStateChanged( const RimWorkflowJob* job ); + protected: void closeEvent( QCloseEvent* event ) override; QStringList defaultDockStateNames() override; @@ -138,6 +146,7 @@ private: void createMenus(); void createToolBars(); void createDockPanels(); + void showWorkflowGraph( RimWorkflow* workflow, RimWorkflowJob* job ); void restoreTreeViewState(); @@ -277,4 +286,7 @@ private: QToolBar* m_holoLensToolBar; std::vector> m_additionalProjectViews; + QPointer m_workflowGraphDock; + QPointer m_workflowGraphView; + caf::PdmPointer m_displayedWorkflowJob; }; diff --git a/ApplicationLibCode/UserInterface/RiuWorkflowGraphView.cpp b/ApplicationLibCode/UserInterface/RiuWorkflowGraphView.cpp new file mode 100644 index 0000000000..06eece6348 --- /dev/null +++ b/ApplicationLibCode/UserInterface/RiuWorkflowGraphView.cpp @@ -0,0 +1,534 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RiuWorkflowGraphView.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace +{ +constexpr qreal nodeWidth = 240.0; +constexpr qreal configWidth = 190.0; +constexpr qreal portStep = 40.0; +constexpr qreal portTop = 51.0; +constexpr qreal columnStep = 650.0; +constexpr qreal configGap = 110.0; +constexpr qreal rowGap = 55.0; + +QStringList fieldPorts( const QJsonArray& fields ) +{ + QStringList keys; + for ( const QJsonValue& field : fields ) + { + if ( field.isString() && !field.toString().isEmpty() ) keys.append( "field:" + field.toString() ); + } + keys.removeDuplicates(); + keys.sort(); + return keys; +} + +QString inputPortKey( const QJsonObject& edge ) +{ + const QString field = edge.value( "input" ).toString(); + return field.isEmpty() ? "model" : "field:" + field; +} + +QString outputPortKey( const QJsonObject& edge ) +{ + const QString field = edge.value( "output" ).toString(); + return field.isEmpty() ? "model" : "field:" + field; +} + +class GraphEdge; + +class GraphNode : public QGraphicsRectItem +{ +public: + GraphNode( const QString& name, QStringList inputs, QStringList outputs, bool isConfig = false ); + + qreal height() const; + QPointF portPosition( const QString& key, bool output ) const; + void addEdge( GraphEdge* edge ); + QString name() const; + bool isConfig() const; + void setConfigValue( const QString& fieldName, const QString& value ); + void setTaskState( const QString& state, const QString& error = {} ); + +protected: + QVariant itemChange( GraphicsItemChange change, const QVariant& value ) override; + +private: + void addPorts( const QStringList& keys, bool output ); + + QString m_name; + bool m_isConfig; + qreal m_width; + QMap m_inputs; + QMap m_outputs; + QMap m_configValues; + std::vector m_edges; + QGraphicsTextItem* m_stateLabel = nullptr; +}; + +class GraphEdge : public QGraphicsPathItem +{ +public: + GraphEdge( GraphNode* from, QString outputKey, GraphNode* to, QString inputKey, bool isConfig = false ); + + void updatePath(); + +private: + GraphNode* m_from; + GraphNode* m_to; + QString m_outputKey; + QString m_inputKey; + QGraphicsPolygonItem* m_arrow; +}; + +GraphNode::GraphNode( const QString& name, QStringList inputs, QStringList outputs, bool isConfig ) + : m_name( name ) + , m_isConfig( isConfig ) + , m_width( isConfig ? configWidth : nodeWidth ) +{ + setRect( 0, 0, m_width, portTop + portStep * std::max( { qsizetype( 1 ), inputs.size(), outputs.size() } ) + 12 ); + setPen( QPen( isConfig ? QColor( 140, 105, 45 ) : QColor( 60, 85, 115 ) ) ); + setBrush( isConfig ? QColor( 253, 244, 218 ) : QColor( 226, 238, 250 ) ); + setFlags( ItemIsMovable | ItemIsSelectable | ItemSendsGeometryChanges ); + setZValue( 1 ); + setToolTip( name ); + + QFont titleFont; + titleFont.setBold( true ); + const QString titleText = isConfig ? "Config: " + name : name; + auto* title = + new QGraphicsTextItem( QFontMetrics( titleFont ).elidedText( titleText, Qt::ElideRight, m_width - ( isConfig ? 20 : 100 ) ), this ); + title->setFont( titleFont ); + title->setToolTip( titleText ); + title->setPos( 10, 4 ); + title->setAcceptedMouseButtons( Qt::NoButton ); + + if ( !isConfig ) + { + m_stateLabel = new QGraphicsTextItem( this ); + QFont font; + font.setPointSize( 8 ); + font.setBold( true ); + m_stateLabel->setFont( font ); + m_stateLabel->setPos( m_width - 91, 5 ); + m_stateLabel->setAcceptedMouseButtons( Qt::NoButton ); + } + + addPorts( inputs, false ); + addPorts( outputs, true ); +} + +qreal GraphNode::height() const +{ + return rect().height(); +} + +void GraphNode::addPorts( const QStringList& keys, bool output ) +{ + auto& ports = output ? m_outputs : m_inputs; + for ( int row = 0; row < keys.size(); ++row ) + { + const QString& key = keys[row]; + const qreal x = output ? m_width : 0.0; + const qreal y = portTop + row * portStep; + auto* port = new QGraphicsEllipseItem( x - 5, y - 5, 10, 10, this ); + const QString label = key == "model" ? ( output ? "output" : "input" ) : key.mid( 6 ); + port->setBrush( m_isConfig ? QColor( 170, 110, 35 ) : ( output ? QColor( 50, 115, 165 ) : QColor( 75, 145, 95 ) ) ); + port->setPen( Qt::NoPen ); + port->setToolTip( label ); + port->setAcceptedMouseButtons( Qt::NoButton ); + ports.insert( key, port ); + + QFont font; + font.setPointSize( 9 ); + const QString display = QFontMetrics( font ).elidedText( label, Qt::ElideRight, m_isConfig ? m_width - 24 : m_width / 2 - 18 ); + auto* text = new QGraphicsTextItem( display, this ); + text->setFont( font ); + text->setToolTip( label ); + text->setDefaultTextColor( QColor( 45, 65, 80 ) ); + text->setPos( output ? m_width - text->boundingRect().width() - 12 : 10, y - ( m_isConfig || !output ? 20 : 13 ) ); + text->setAcceptedMouseButtons( Qt::NoButton ); + } +} + +QPointF GraphNode::portPosition( const QString& key, bool output ) const +{ + const auto& ports = output ? m_outputs : m_inputs; + auto* port = ports.value( key, nullptr ); + return port ? port->mapToScene( port->rect().center() ) : scenePos(); +} + +void GraphNode::addEdge( GraphEdge* edge ) +{ + m_edges.push_back( edge ); +} + +QString GraphNode::name() const +{ + return m_name; +} + +bool GraphNode::isConfig() const +{ + return m_isConfig; +} + +void GraphNode::setTaskState( const QString& state, const QString& error ) +{ + if ( m_isConfig ) return; + + QColor border( 60, 85, 115 ); + QColor background( 226, 238, 250 ); + if ( state == "running" ) + { + border = QColor( 158, 112, 16 ); + background = QColor( 255, 241, 189 ); + } + else if ( state == "completed" ) + { + border = QColor( 42, 120, 65 ); + background = QColor( 215, 242, 222 ); + } + else if ( state == "failed" ) + { + border = QColor( 165, 50, 50 ); + background = QColor( 253, 225, 225 ); + } + else if ( state == "interrupted" ) + { + border = QColor( 140, 85, 50 ); + background = QColor( 245, 228, 210 ); + } + setPen( QPen( border, state.isEmpty() ? 1 : 2 ) ); + setBrush( background ); + m_stateLabel->setDefaultTextColor( border ); + m_stateLabel->setPlainText( state.isEmpty() ? "" : state.at( 0 ).toUpper() + state.mid( 1 ) ); + setToolTip( error.isEmpty() ? m_name : m_name + "\n" + error ); +} + +void GraphNode::setConfigValue( const QString& fieldName, const QString& value ) +{ + if ( !m_isConfig ) return; + auto* port = m_outputs.value( "field:" + fieldName, nullptr ); + if ( !port ) return; + + auto* text = m_configValues.value( fieldName, nullptr ); + if ( !text ) + { + text = new QGraphicsTextItem( this ); + QFont font; + font.setPointSize( 8 ); + text->setFont( font ); + text->setDefaultTextColor( QColor( 125, 80, 30 ) ); + text->setPos( 10, port->rect().center().y() - 2 ); + text->setAcceptedMouseButtons( Qt::NoButton ); + m_configValues.insert( fieldName, text ); + } + + text->setPlainText( QFontMetrics( text->font() ).elidedText( value, Qt::ElideRight, m_width - 24 ) ); + text->setToolTip( fieldName + ": " + value ); +} + +QVariant GraphNode::itemChange( GraphicsItemChange change, const QVariant& value ) +{ + if ( change == ItemPositionHasChanged ) + { + for ( GraphEdge* edge : m_edges ) + edge->updatePath(); + if ( scene() ) scene()->setSceneRect( scene()->sceneRect().united( scene()->itemsBoundingRect().adjusted( -50, -50, 50, 50 ) ) ); + } + return QGraphicsRectItem::itemChange( change, value ); +} + +GraphEdge::GraphEdge( GraphNode* from, QString outputKey, GraphNode* to, QString inputKey, bool isConfig ) + : m_from( from ) + , m_to( to ) + , m_outputKey( std::move( outputKey ) ) + , m_inputKey( std::move( inputKey ) ) + , m_arrow( new QGraphicsPolygonItem( this ) ) +{ + QPen pen( isConfig ? QColor( 170, 110, 35 ) : QColor( 75, 95, 115 ), 1.7 ); + if ( isConfig ) pen.setStyle( Qt::DashLine ); + setPen( pen ); + setZValue( -1 ); + m_arrow->setPen( Qt::NoPen ); + m_arrow->setBrush( pen.color() ); + m_from->addEdge( this ); + m_to->addEdge( this ); + updatePath(); +} + +void GraphEdge::updatePath() +{ + const QPointF start = m_from->portPosition( m_outputKey, true ); + const QPointF end = m_to->portPosition( m_inputKey, false ); + const qreal bend = std::max( 35.0, std::abs( end.x() - start.x() ) / 2.0 ); + QPainterPath path( start ); + path.cubicTo( start + QPointF( bend, 0 ), end - QPointF( bend, 0 ), end ); + setPath( path ); + m_arrow->setPolygon( QPolygonF{ end, end + QPointF( -9, -5 ), end + QPointF( -9, 5 ) } ); +} +} // namespace + +RiuWorkflowGraphView::RiuWorkflowGraphView( QWidget* parent ) + : QGraphicsView( parent ) + , m_scene( new QGraphicsScene( this ) ) +{ + setScene( m_scene ); + setRenderHint( QPainter::Antialiasing ); + setDragMode( QGraphicsView::ScrollHandDrag ); + setTransformationAnchor( QGraphicsView::AnchorUnderMouse ); +} + +void RiuWorkflowGraphView::showGraph( const QJsonObject& graph, const QString& error ) +{ + m_runStatusLabel = nullptr; + m_scene->clear(); + m_fitOnResize = true; + + if ( !error.isEmpty() || graph.value( "tasks" ).toArray().isEmpty() ) + { + m_scene->addText( error.isEmpty() ? "No tasks in this workflow" : error ); + fitInView( m_scene->itemsBoundingRect().adjusted( -30, -30, 30, 30 ), Qt::KeepAspectRatio ); + return; + } + + const QJsonArray tasks = graph.value( "tasks" ).toArray(); + const QJsonArray edges = graph.value( "edges" ).toArray(); + QMap ranks; + QMap inputs; + QMap outputs; + QMap declaredOutputs; + QMap configFields; + for ( const QJsonValue& task : tasks ) + { + const QJsonObject data = task.toObject(); + const QString name = data.value( "name" ).toString(); + if ( name.isEmpty() || ranks.contains( name ) ) continue; + ranks.insert( name, 0 ); + inputs.insert( name, fieldPorts( data.value( "inputs" ).toArray() ) ); + declaredOutputs.insert( name, fieldPorts( data.value( "outputs" ).toArray() ) ); + QStringList configured; + for ( const QJsonValue& field : data.value( "config_fields" ).toArray() ) + { + const QString fieldName = field.toObject().value( "name" ).toString(); + if ( !fieldName.isEmpty() ) configured.append( "field:" + fieldName ); + } + configured.removeDuplicates(); + configured.sort(); + configFields.insert( name, configured ); + inputs[name].append( configured ); + } + + // The Python helper provides tasks in topological order. + for ( const QJsonValue& task : tasks ) + { + const QString name = task.toObject().value( "name" ).toString(); + if ( !ranks.contains( name ) ) continue; + for ( const QJsonValue& value : edges ) + { + const QJsonObject edge = value.toObject(); + if ( edge.value( "to" ).toString() == name && ranks.contains( edge.value( "from" ).toString() ) ) + { + ranks[name] = std::max( ranks[name], ranks.value( edge.value( "from" ).toString() ) + 1 ); + } + } + } + + for ( const QJsonValue& value : edges ) + { + const QJsonObject edge = value.toObject(); + const QString from = edge.value( "from" ).toString(); + const QString to = edge.value( "to" ).toString(); + if ( !ranks.contains( from ) || !ranks.contains( to ) || from == to ) continue; + inputs[to].append( inputPortKey( edge ) ); + outputs[from].append( outputPortKey( edge ) ); + } + + QMap layers; + QMap nodes; + QMap configNodes; + for ( auto it = ranks.cbegin(); it != ranks.cend(); ++it ) + { + layers[it.value()].append( it.key() ); + QStringList in = inputs.value( it.key() ); + QStringList out = outputs.value( it.key() ); + // Non-terminal tasks only need ports used by outgoing connections. + // Keep declared fields visible on terminal tasks as workflow results. + if ( out.isEmpty() ) out = declaredOutputs.value( it.key() ); + if ( out.isEmpty() ) out.append( "model" ); + in.removeDuplicates(); + out.removeDuplicates(); + in.sort(); + out.sort(); + auto* node = new GraphNode( it.key(), in, out ); + m_scene->addItem( node ); + nodes.insert( it.key(), node ); + if ( !configFields.value( it.key() ).isEmpty() ) + { + auto* configNode = new GraphNode( it.key(), {}, configFields.value( it.key() ), true ); + m_scene->addItem( configNode ); + configNodes.insert( it.key(), configNode ); + } + } + + auto slotHeight = [&nodes, &configNodes]( const QString& name ) + { + qreal height = nodes.value( name )->height(); + if ( auto* config = configNodes.value( name, nullptr ) ) height = std::max( height, config->height() ); + return height; + }; + + qreal totalHeight = 0; + for ( const QStringList& layer : layers ) + { + qreal height = 0; + for ( const QString& name : layer ) + height += slotHeight( name ) + rowGap; + totalHeight = std::max( totalHeight, height - rowGap ); + } + for ( auto it = layers.cbegin(); it != layers.cend(); ++it ) + { + qreal layerHeight = 0; + for ( const QString& name : it.value() ) + layerHeight += slotHeight( name ) + rowGap; + qreal y = ( totalHeight - layerHeight + rowGap ) / 2.0; + for ( const QString& name : it.value() ) + { + auto* node = nodes.value( name ); + const qreal x = it.key() * columnStep + configWidth + configGap; + node->setPos( x, y + ( slotHeight( name ) - node->height() ) / 2.0 ); + if ( auto* config = configNodes.value( name, nullptr ) ) + { + config->setPos( x - configWidth - configGap, y + ( slotHeight( name ) - config->height() ) / 2.0 ); + } + y += slotHeight( name ) + rowGap; + } + } + + for ( const QJsonValue& value : edges ) + { + const QJsonObject edge = value.toObject(); + const QString from = edge.value( "from" ).toString(); + const QString to = edge.value( "to" ).toString(); + if ( !nodes.contains( from ) || !nodes.contains( to ) || from == to ) continue; + m_scene->addItem( new GraphEdge( nodes.value( from ), outputPortKey( edge ), nodes.value( to ), inputPortKey( edge ) ) ); + } + + for ( auto it = configNodes.cbegin(); it != configNodes.cend(); ++it ) + { + for ( const QString& key : configFields.value( it.key() ) ) + { + m_scene->addItem( new GraphEdge( it.value(), key, nodes.value( it.key() ), key, true ) ); + } + } + + m_runStatusLabel = m_scene->addText( {} ); + m_runStatusLabel->setDefaultTextColor( QColor( 45, 65, 80 ) ); + m_runStatusLabel->setPos( 0, -45 ); + m_scene->setSceneRect( m_scene->itemsBoundingRect().adjusted( -50, -50, 50, 50 ) ); + fitInView( m_scene->sceneRect(), Qt::KeepAspectRatio ); +} + +void RiuWorkflowGraphView::setTaskInputValue( const QString& taskName, const QString& fieldName, const QString& value ) +{ + for ( QGraphicsItem* item : m_scene->items() ) + { + auto* node = dynamic_cast( item ); + if ( node && node->isConfig() && node->name() == taskName ) + { + node->setConfigValue( fieldName, value ); + return; + } + } +} + +void RiuWorkflowGraphView::resetTaskStates() +{ + for ( QGraphicsItem* item : m_scene->items() ) + { + if ( auto* node = dynamic_cast( item ) ) node->setTaskState( {} ); + } +} + +void RiuWorkflowGraphView::setTaskState( const QString& taskName, const QString& state, const QString& error ) +{ + for ( QGraphicsItem* item : m_scene->items() ) + { + auto* node = dynamic_cast( item ); + if ( node && !node->isConfig() && node->name() == taskName ) + { + node->setTaskState( state, error ); + return; + } + } +} + +void RiuWorkflowGraphView::setRunStatus( const QString& status ) +{ + if ( !m_runStatusLabel ) return; + m_runStatusLabel->setPlainText( QFontMetrics( m_runStatusLabel->font() ).elidedText( status, Qt::ElideRight, 580 ) ); + m_runStatusLabel->setToolTip( status ); +} + +void RiuWorkflowGraphView::resizeEvent( QResizeEvent* event ) +{ + QGraphicsView::resizeEvent( event ); + if ( m_fitOnResize && !m_scene->sceneRect().isEmpty() ) fitInView( m_scene->sceneRect(), Qt::KeepAspectRatio ); +} + +void RiuWorkflowGraphView::mousePressEvent( QMouseEvent* event ) +{ + if ( event->button() == Qt::LeftButton ) m_fitOnResize = false; + QGraphicsView::mousePressEvent( event ); +} + +void RiuWorkflowGraphView::wheelEvent( QWheelEvent* event ) +{ + if ( event->modifiers() & Qt::ControlModifier ) + { + m_fitOnResize = false; + const qreal factor = event->angleDelta().y() > 0 ? 1.15 : 1.0 / 1.15; + scale( factor, factor ); + event->accept(); + return; + } + QGraphicsView::wheelEvent( event ); +} diff --git a/ApplicationLibCode/UserInterface/RiuWorkflowGraphView.h b/ApplicationLibCode/UserInterface/RiuWorkflowGraphView.h new file mode 100644 index 0000000000..99236e10bc --- /dev/null +++ b/ApplicationLibCode/UserInterface/RiuWorkflowGraphView.h @@ -0,0 +1,48 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// at for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include + +class QGraphicsScene; +class QGraphicsTextItem; +class QMouseEvent; +class QResizeEvent; +class QWheelEvent; + +class RiuWorkflowGraphView : public QGraphicsView +{ +public: + explicit RiuWorkflowGraphView( QWidget* parent = nullptr ); + + void showGraph( const QJsonObject& graph, const QString& error ); + void setTaskInputValue( const QString& taskName, const QString& fieldName, const QString& value ); + void resetTaskStates(); + void setTaskState( const QString& taskName, const QString& state, const QString& error = {} ); + void setRunStatus( const QString& status ); + +protected: + void resizeEvent( QResizeEvent* event ) override; + void mousePressEvent( QMouseEvent* event ) override; + void wheelEvent( QWheelEvent* event ) override; + +private: + QGraphicsScene* m_scene; + QGraphicsTextItem* m_runStatusLabel = nullptr; + bool m_fitOnResize = true; +}; diff --git a/ApplicationLibCode/UserInterface/RiuWorkflowJobRunner.cpp b/ApplicationLibCode/UserInterface/RiuWorkflowJobRunner.cpp index b39cddfc92..4a777eebe2 100644 --- a/ApplicationLibCode/UserInterface/RiuWorkflowJobRunner.cpp +++ b/ApplicationLibCode/UserInterface/RiuWorkflowJobRunner.cpp @@ -18,6 +18,18 @@ #include "RiuWorkflowJobRunner.h" +#include "RiaQStringFormatter.h" + +#include +#include + +#include + +namespace +{ +constexpr char PROGRESS_PREFIX[] = "@@RI_WORKFLOW_EVENT@@"; +} + RiuWorkflowJobRunner::RiuWorkflowJobRunner( const QString& label, QObject* parent ) : QObject( parent ) , m_label( label ) @@ -25,6 +37,7 @@ RiuWorkflowJobRunner::RiuWorkflowJobRunner( const QString& label, QObject* paren 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 ); + connect( &m_process, &QProcess::errorOccurred, this, &RiuWorkflowJobRunner::onProcessError ); } void RiuWorkflowJobRunner::start( const QString& program, const QStringList& arguments, const QProcessEnvironment& env ) @@ -38,6 +51,7 @@ void RiuWorkflowJobRunner::start( const QString& program, const QStringList& arg void RiuWorkflowJobRunner::cancel() { if ( m_process.state() == QProcess::NotRunning ) return; + m_cancelRequested = true; m_process.terminate(); if ( !m_process.waitForFinished( 2000 ) ) m_process.kill(); } @@ -69,7 +83,11 @@ void RiuWorkflowJobRunner::onProcessFinished( int exitCode, QProcess::ExitStatus drainLines( m_stdoutBuf, RILogLevel::RI_LL_INFO ); drainLines( m_stderrBuf, RILogLevel::RI_LL_WARNING ); - if ( status == QProcess::NormalExit && exitCode == 0 ) + if ( m_cancelRequested ) + { + RiaLogging::info( std::format( "Cancelled {}", m_label ) ); + } + else if ( status == QProcess::NormalExit && exitCode == 0 ) { RiaLogging::info( QString( "Finished %1 (exit 0)" ).arg( m_label ).toStdString() ); } @@ -82,6 +100,20 @@ void RiuWorkflowJobRunner::onProcessFinished( int exitCode, QProcess::ExitStatus RiaLogging::error( QString( "%1 crashed" ).arg( m_label ).toStdString() ); } + if ( !m_finishedEmitted ) + { + m_finishedEmitted = true; + emit runFinished( status == QProcess::NormalExit && exitCode == 0 && !m_cancelRequested, m_cancelRequested ); + } + deleteLater(); +} + +void RiuWorkflowJobRunner::onProcessError( QProcess::ProcessError error ) +{ + if ( error != QProcess::FailedToStart || m_finishedEmitted ) return; + m_finishedEmitted = true; + RiaLogging::error( std::format( "Failed to launch {}: {}", m_label, m_process.errorString() ) ); + emit runFinished( false, false ); deleteLater(); } @@ -95,6 +127,24 @@ void RiuWorkflowJobRunner::drainLines( QByteArray& buffer, RILogLevel level ) if ( line.endsWith( '\r' ) ) line.chop( 1 ); if ( line.isEmpty() ) continue; + if ( level == RILogLevel::RI_LL_INFO && line.startsWith( PROGRESS_PREFIX ) ) + { + const QJsonDocument doc = QJsonDocument::fromJson( line.mid( sizeof( PROGRESS_PREFIX ) - 1 ).toUtf8() ); + if ( doc.isObject() ) + { + const QJsonObject event = doc.object(); + if ( event.value( "event" ) == "task_state" && event.value( "run_id" ).isString() && event.value( "task" ).isString() && + event.value( "state" ).isString() ) + { + emit taskStateChanged( event.value( "run_id" ).toString(), + event.value( "task" ).toString(), + event.value( "state" ).toString(), + event.value( "error" ).toString() ); + continue; + } + } + } + const std::string msg = QString( "[%1] %2" ).arg( m_label, line ).toStdString(); if ( level == RILogLevel::RI_LL_WARNING ) RiaLogging::warning( msg ); diff --git a/ApplicationLibCode/UserInterface/RiuWorkflowJobRunner.h b/ApplicationLibCode/UserInterface/RiuWorkflowJobRunner.h index 7b16c240d1..ff525f706e 100644 --- a/ApplicationLibCode/UserInterface/RiuWorkflowJobRunner.h +++ b/ApplicationLibCode/UserInterface/RiuWorkflowJobRunner.h @@ -36,10 +36,15 @@ public: void cancel(); bool isRunning() const; +signals: + void taskStateChanged( const QString& runId, const QString& taskName, const QString& state, const QString& error ); + void runFinished( bool succeeded, bool cancelled ); + private slots: void onReadyReadStdout(); void onReadyReadStderr(); void onProcessFinished( int exitCode, QProcess::ExitStatus status ); + void onProcessError( QProcess::ProcessError error ); private: void drainLines( QByteArray& buffer, RILogLevel level ); @@ -48,4 +53,6 @@ private: QProcess m_process; QByteArray m_stdoutBuf; QByteArray m_stderrBuf; + bool m_cancelRequested = false; + bool m_finishedEmitted = false; }; diff --git a/GrpcInterface/Python/rips/taskmaestro_helper/introspect.py b/GrpcInterface/Python/rips/taskmaestro_helper/introspect.py index 00a9a842ff..4eb5ad9dc0 100644 --- a/GrpcInterface/Python/rips/taskmaestro_helper/introspect.py +++ b/GrpcInterface/Python/rips/taskmaestro_helper/introspect.py @@ -10,6 +10,7 @@ introspection. from __future__ import annotations import datetime +import enum import json import pathlib import sys @@ -80,6 +81,12 @@ def _serialize_default(value: Any) -> Any: return value.isoformat() if isinstance(value, pathlib.PurePath): return str(value) + if isinstance(value, enum.Enum): + return _serialize_default(value.value) + if isinstance(value, dict): + return {key: _serialize_default(item) for key, item in value.items()} + if isinstance(value, (list, tuple, set, frozenset)): + return [_serialize_default(item) for item in value] return value @@ -96,12 +103,11 @@ def _field_schema( # 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: + if input_value is not _SENTINEL: entry["default"] = _serialize_default(input_value) elif not field_info.is_required(): - default = field_info.get_default(call_default_factory=False) - if default is not None: - entry["default"] = _serialize_default(default) + default = field_info.get_default(call_default_factory=True) + entry["default"] = _serialize_default(default) fmt = _format_for(field_info) if fmt is not None: @@ -112,6 +118,38 @@ def _field_schema( return entry +def _dependency_edges(task_name: str, deps: Any) -> list[dict[str, str]]: + """Flatten taskmaestro's resolved dependency forms into directed edges.""" + if deps is None: + return [] + if isinstance(deps, str): + return [{"from": deps, "to": task_name}] + if isinstance(deps, tuple): + return [{"from": deps[0], "to": task_name, "output": deps[1]}] + + edges = [] + for input_name, upstream in sorted(deps.items()): + edge = { + "from": upstream[0] if isinstance(upstream, tuple) else upstream, + "to": task_name, + "input": input_name, + } + if isinstance(upstream, tuple): + edge["output"] = upstream[1] + edges.append(edge) + return edges + + +def _output_fields(output_type: type) -> list[str]: + """Expose output fields, but not ObjectModel's internal wrapped value.""" + from taskmaestro import ObjectModel + + fields = set(output_type.model_fields) + if issubclass(output_type, ObjectModel): + fields.discard("value") + return sorted(fields) + + 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" @@ -125,7 +163,7 @@ def collect_schema(workflow_dir: Path) -> dict[str, Any]: if workflow_dir_str not in sys.path: sys.path.insert(0, workflow_dir_str) - from taskmaestro.task import get_input_type + from taskmaestro.task import get_input_type, get_output_type from taskmaestro.yaml_config import _load_workflow_only wf, jc = _load_workflow_only( @@ -134,11 +172,10 @@ def collect_schema(workflow_dir: Path) -> dict[str, Any]: ) tasks: list[dict[str, Any]] = [] + edges: list[dict[str, str]] = [] for task_name, task_cls in wf.topological_order(): + edges.extend(_dependency_edges(task_name, wf.get_dependencies(task_name))) config_field_names = wf.get_config_fields(task_name) - if not config_field_names: - continue - task_config = jc.get_config_for_task(task_name) if jc is not None else {} input_type = get_input_type(task_cls) @@ -160,12 +197,20 @@ def collect_schema(workflow_dir: Path) -> dict[str, Any]: _field_schema(field_name, field_info, input_value=input_value) ) - tasks.append({"name": task_name, "config_fields": config_fields}) + tasks.append( + { + "name": task_name, + "inputs": sorted(input_type.model_fields), + "outputs": _output_fields(get_output_type(task_cls)), + "config_fields": config_fields, + } + ) return { "name": wf.name, "description": "", "tasks": tasks, + "edges": edges, } diff --git a/GrpcInterface/Python/rips/taskmaestro_helper/run.py b/GrpcInterface/Python/rips/taskmaestro_helper/run.py index 9c4e7e65f0..8ed05d409e 100644 --- a/GrpcInterface/Python/rips/taskmaestro_helper/run.py +++ b/GrpcInterface/Python/rips/taskmaestro_helper/run.py @@ -23,6 +23,33 @@ import yaml from .refs import REF_MARKER +PROGRESS_PREFIX = "@@RI_WORKFLOW_EVENT@@" + + +def _emit_progress(run_id: str, task: str, state: str, error: str = "") -> None: + payload = {"event": "task_state", "run_id": run_id, "task": task, "state": state} + if error: + payload["error"] = error + sys.stdout.write(PROGRESS_PREFIX + json.dumps(payload) + "\n") + sys.stdout.flush() + + +class ProgressHook: + """Report taskmaestro's resolved task-instance lifecycle to ResInsight.""" + + def __init__(self, run_id: str) -> None: + self.run_id = run_id + + def on_task_start(self, job: Any, task: Any) -> None: + _emit_progress(self.run_id, task.name, "running") + + def on_task_complete(self, job: Any, task: Any, output: Any) -> None: + _emit_progress(self.run_id, task.name, "completed") + + def on_task_fail(self, job: Any, task: Any, error: Exception) -> None: + _emit_progress(self.run_id, task.name, "failed", str(error)) + + def _emit(event: str, **fields: Any) -> None: sys.stdout.write(json.dumps({"event": event, **fields}) + "\n") sys.stdout.flush() @@ -64,7 +91,9 @@ def make_rips_resolver(rips_instance: Any) -> Callable[[str, dict[str, Any]], An return resolve -def run_workflow(workflow_dir: Path, input_path: Path, grpc_port: int) -> int: +def run_workflow( + workflow_dir: Path, input_path: Path, grpc_port: int, run_id: str = "" +) -> int: workflow_dir_str = str(workflow_dir) if workflow_dir_str not in sys.path: sys.path.insert(0, workflow_dir_str) @@ -102,7 +131,7 @@ def run_workflow(workflow_dir: Path, input_path: Path, grpc_port: int) -> int: _emit("starting", workflow=workflow.name) try: - result = Runner().run(job, ctx=ExecutionContext()) + result = Runner(hooks=[ProgressHook(run_id)]).run(job, ctx=ExecutionContext()) except Exception as exc: _emit("error", message=str(exc)) return 1 @@ -120,7 +149,8 @@ def main(argv: list[str]) -> int: parser.add_argument("workflow_dir", type=Path) parser.add_argument("--input", type=Path, required=True) parser.add_argument("--grpc-port", type=int, required=True) + parser.add_argument("--run-id", default="") args = parser.parse_args(argv) return run_workflow( - args.workflow_dir.resolve(), args.input.resolve(), args.grpc_port + args.workflow_dir.resolve(), args.input.resolve(), args.grpc_port, args.run_id ) diff --git a/GrpcInterface/Python/rips/tests/test_taskmaestro_helper.py b/GrpcInterface/Python/rips/tests/test_taskmaestro_helper.py index bbf94a2e58..bc6021f3b8 100644 --- a/GrpcInterface/Python/rips/tests/test_taskmaestro_helper.py +++ b/GrpcInterface/Python/rips/tests/test_taskmaestro_helper.py @@ -97,14 +97,26 @@ def test_collect_schema_returns_workflow_name_and_tasks(workflow_dir: Path) -> N 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 + assert task_names == ["start", "greet"] + assert schema["tasks"][0]["config_fields"] == [] + assert schema["tasks"][0]["inputs"] == [] + assert schema["tasks"][0]["outputs"] == [] + assert schema["tasks"][1]["inputs"] == [ + "name", + "out_dir", + "out_file", + "times", + "when", + ] + assert schema["tasks"][1]["outputs"] == ["message"] + assert schema["edges"] == [{"from": "start", "to": "greet"}] 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"] + greet = schema["tasks"][1] 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" @@ -136,6 +148,72 @@ def test_collect_schema_extracts_field_types_and_metadata(workflow_dir: Path) -> assert fields_by_name["out_dir"]["default"] == "/tmp" +def test_field_schema_preserves_arrays_and_null_defaults() -> None: + from pydantic import BaseModel, Field + + from rips.taskmaestro_helper.introspect import _field_schema + + class Config(BaseModel): + values: list[int] = Field(default_factory=lambda: [1, 2]) + optional_count: int | None = None + + values = _field_schema("values", Config.model_fields["values"]) + optional_count = _field_schema( + "optional_count", Config.model_fields["optional_count"] + ) + + assert values["type"] == "array" + assert values["default"] == [1, 2] + assert optional_count["type"] == "integer" + assert "default" in optional_count + assert optional_count["default"] is None + + +def test_field_schema_preserves_explicit_null_input() -> None: + from pydantic import BaseModel + + from rips.taskmaestro_helper.introspect import _field_schema + + class Config(BaseModel): + optional_name: str | None = "fallback" + + schema = _field_schema( + "optional_name", Config.model_fields["optional_name"], input_value=None + ) + assert "default" in schema + assert schema["default"] is None + + +def test_object_model_value_is_not_an_output_port() -> None: + from pydantic import BaseModel + from taskmaestro import ObjectModel + + from rips.taskmaestro_helper.introspect import _output_fields + + class WrappedOutput(ObjectModel[str]): + other: int + + class PlainOutput(BaseModel): + value: str + other: int + + assert _output_fields(WrappedOutput) == ["other"] + assert _output_fields(PlainOutput) == ["other", "value"] + + +def test_dependency_edges_cover_fan_in_and_field_routing() -> None: + from rips.taskmaestro_helper.introspect import _dependency_edges + + assert _dependency_edges("root", None) == [] + assert _dependency_edges("next", ("producer", "value")) == [ + {"from": "producer", "to": "next", "output": "value"} + ] + assert _dependency_edges("merge", {"b": ("second", "value"), "a": "first"}) == [ + {"from": "first", "to": "merge", "input": "a"}, + {"from": "second", "to": "merge", "input": "b", "output": "value"}, + ] + + def test_resolve_refs_substitutes_object_model_value() -> None: from rips.taskmaestro_helper.run import resolve_refs @@ -166,6 +244,138 @@ def test_resolve_refs_substitutes_object_model_value() -> None: assert out["untouched"] == "stays" +def test_progress_hook_reports_named_task_instances( + capsys: pytest.CaptureFixture[str], +) -> None: + from types import SimpleNamespace + + from rips.taskmaestro_helper.run import PROGRESS_PREFIX, ProgressHook + + hook = ProgressHook("run-123") + task = SimpleNamespace(name="add_perf_1") + hook.on_task_start(None, task) + hook.on_task_complete(None, task, None) + hook.on_task_fail(None, task, ValueError("bad range")) + + lines = capsys.readouterr().out.splitlines() + assert len(lines) == 3 + events = [json.loads(line.removeprefix(PROGRESS_PREFIX)) for line in lines] + assert all(line.startswith(PROGRESS_PREFIX) for line in lines) + assert [event["state"] for event in events] == ["running", "completed", "failed"] + assert all( + event["task"] == "add_perf_1" and event["run_id"] == "run-123" + for event in events + ) + assert events[-1]["error"] == "bad range" + assert all("output" not in event for event in events) + + +def test_progress_hook_follows_runner_instance_names( + capsys: pytest.CaptureFixture[str], +) -> None: + from taskmaestro import EmptyConfig, ExecutionContext, Job, Runner, Task, Workflow + + from rips.taskmaestro_helper.run import PROGRESS_PREFIX, ProgressHook + + class Step(Task[EmptyConfig, EmptyConfig]): + name = "step" + + def run(self, input: EmptyConfig, ctx: ExecutionContext) -> EmptyConfig: + return EmptyConfig() + + workflow = ( + Workflow.builder("two_steps") + .add_task(Step, name="first") + .add_task(Step, name="second", depends_on="first") + .build() + ) + Runner(hooks=[ProgressHook("run-456")]).run( + Job(workflow, EmptyConfig()), ctx=ExecutionContext() + ) + events = [ + json.loads(line.removeprefix(PROGRESS_PREFIX)) + for line in capsys.readouterr().out.splitlines() + if line.startswith(PROGRESS_PREFIX) + ] + assert [(event["task"], event["state"]) for event in events] == [ + ("first", "running"), + ("first", "completed"), + ("second", "running"), + ("second", "completed"), + ] + + +def test_progress_hook_reports_runner_failure( + capsys: pytest.CaptureFixture[str], +) -> None: + from taskmaestro import EmptyConfig, ExecutionContext, Job, Runner, Task, Workflow + + from rips.taskmaestro_helper.run import PROGRESS_PREFIX, ProgressHook + + class Fails(Task[EmptyConfig, EmptyConfig]): + name = "fails" + + def run(self, input: EmptyConfig, ctx: ExecutionContext) -> EmptyConfig: + raise ValueError("bad range") + + workflow = Workflow.builder("failure").add_task(Fails, name="add_perf_1").build() + result = Runner(hooks=[ProgressHook("run-789")]).run( + Job(workflow, EmptyConfig()), ctx=ExecutionContext() + ) + assert result.error == "bad range" + events = [ + json.loads(line.removeprefix(PROGRESS_PREFIX)) + for line in capsys.readouterr().out.splitlines() + if line.startswith(PROGRESS_PREFIX) + ] + assert [(event["task"], event["state"]) for event in events] == [ + ("add_perf_1", "running"), + ("add_perf_1", "failed"), + ] + assert events[-1]["error"] == "bad range" + + +def test_run_helper_emits_progress_without_grpc( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + import rips + + from rips.taskmaestro_helper.run import PROGRESS_PREFIX, run_workflow + + (tmp_path / "progress_pipeline.py").write_text( + "from taskmaestro import EmptyConfig, ExecutionContext, Task\n" + "class Start(Task[EmptyConfig, EmptyConfig]):\n" + " name = 'start'\n" + " def run(self, input: EmptyConfig, ctx: ExecutionContext) -> EmptyConfig:\n" + " return EmptyConfig()\n" + "class Done(Task[EmptyConfig, EmptyConfig]):\n" + " name = 'done'\n" + " def run(self, input: EmptyConfig, ctx: ExecutionContext) -> EmptyConfig:\n" + " return EmptyConfig()\n" + ) + (tmp_path / "workflow.yaml").write_text( + "workflow:\n name: progress_test\n tasks:\n" + " - task: progress_pipeline.Start\n - task: progress_pipeline.Done\n" + ) + input_path = tmp_path / "input.yaml" + input_path.write_text("{}\n") + monkeypatch.setattr(rips.Instance, "find", lambda **kwargs: object()) + + assert run_workflow(tmp_path, input_path, 1234, "run-456") == 0 + events = [ + json.loads(line.removeprefix(PROGRESS_PREFIX)) + for line in capsys.readouterr().out.splitlines() + if line.startswith(PROGRESS_PREFIX) + ] + assert [(event["task"], event["state"]) for event in events] == [ + ("start", "running"), + ("start", "completed"), + ("done", "running"), + ("done", "completed"), + ] + assert all(event["run_id"] == "run-456" for event in events) + + def test_main_emits_json_to_stdout(workflow_dir: Path) -> None: rips_root = Path(__file__).resolve().parents[2] proc = subprocess.run( @@ -183,4 +393,5 @@ def test_main_emits_json_to_stdout(workflow_dir: Path) -> None: ) payload = json.loads(proc.stdout) assert payload["name"] == "synthetic" - assert {t["name"] for t in payload["tasks"]} == {"greet"} + assert {t["name"] for t in payload["tasks"]} == {"start", "greet"} + assert payload["edges"] == [{"from": "start", "to": "greet"}]