mirror of
https://github.com/OPM/ResInsight.git
synced 2026-10-24 22:27:55 -05:00
#14789 Workflow: Visualize taskmaestro DAG in native Qt view
This commit is contained in:
1 parent
5112df4ee0
commit
d63bde00e4
33 files changed
+1338
-36
No files matched your search
@@ -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
|
||||
|
||||
@@ -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<RimWorkflowJob*> RimWorkflow::jobs() const
|
||||
{
|
||||
std::vector<RimWorkflowJob*> 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 );
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
#include "cafPdmField.h"
|
||||
#include "cafPdmObject.h"
|
||||
|
||||
#include <QJsonObject>
|
||||
|
||||
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<caf::FilePath> m_workflowDirectory;
|
||||
caf::PdmField<QString> m_loadError;
|
||||
caf::PdmChildArrayField<RimWorkflowJob*> m_jobs;
|
||||
QJsonObject m_graph;
|
||||
};
|
||||
@@ -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 <http://www.gnu.org/licenses/gpl.html>
|
||||
// for more details.
|
||||
//
|
||||
/////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
#include "RimWorkflowArrayBinding.h"
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
|
||||
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";
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/gpl.html>
|
||||
// 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<QString> m_value;
|
||||
};
|
||||
@@ -38,5 +38,6 @@ void RimWorkflowBoolBinding::applySchema( const QJsonObject& fieldSchema )
|
||||
|
||||
QString RimWorkflowBoolBinding::toYamlValue() const
|
||||
{
|
||||
if ( !hasValue() ) return "null";
|
||||
return m_value() ? "true" : "false";
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -32,6 +32,7 @@ public:
|
||||
RimWorkflowCaseBinding();
|
||||
|
||||
QString toYamlValue() const override;
|
||||
QString displayValue() const override;
|
||||
|
||||
caf::PdmFieldHandle* valueField() override { return &m_case; }
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -18,6 +18,13 @@
|
||||
|
||||
#include "RimWorkflowFieldBinding.h"
|
||||
|
||||
#include "RiuMainWindow.h"
|
||||
|
||||
#include "cafPdmUiFieldHandle.h"
|
||||
|
||||
#include <QDate>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
|
||||
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<RimWorkflowFieldBinding*>( 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() )
|
||||
{
|
||||
|
||||
@@ -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<QString> m_fieldName;
|
||||
caf::PdmField<QString> m_description;
|
||||
caf::PdmField<bool> m_required;
|
||||
caf::PdmField<bool> m_hasValue;
|
||||
};
|
||||
@@ -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 )
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -27,12 +27,15 @@
|
||||
#include "RiuWorkflowJobRunner.h"
|
||||
|
||||
#include "cafCmdFeatureMenuBuilder.h"
|
||||
#include "cafPdmPointer.h"
|
||||
#include "cafPdmUiGroup.h"
|
||||
#include "cafPdmUiOrdering.h"
|
||||
#include "cafPdmUiTreeOrdering.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QProcessEnvironment>
|
||||
#include <QUuid>
|
||||
|
||||
@@ -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<QString, QString> RimWorkflowJob::taskStates() const
|
||||
{
|
||||
return m_taskStates;
|
||||
}
|
||||
|
||||
QMap<QString, QString> 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<RimWorkflow>();
|
||||
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<RimWorkflowJob> 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 );
|
||||
}
|
||||
@@ -24,6 +24,7 @@
|
||||
#include "cafPdmField.h"
|
||||
#include "cafPdmObject.h"
|
||||
|
||||
#include <QMap>
|
||||
#include <QPointer>
|
||||
|
||||
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<QString, QString> taskStates() const;
|
||||
QMap<QString, QString> 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<QString> m_name;
|
||||
caf::PdmChildArrayField<RimWorkflowTaskInput*> m_taskInputs;
|
||||
QPointer<RiuWorkflowJobRunner> m_runner;
|
||||
QMap<QString, QString> m_taskStates;
|
||||
QMap<QString, QString> m_taskErrors;
|
||||
QString m_runStatus;
|
||||
QString m_activeTask;
|
||||
QString m_runId;
|
||||
};
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -32,6 +32,7 @@ public:
|
||||
RimWorkflowViewBinding();
|
||||
|
||||
QString toYamlValue() const override;
|
||||
QString displayValue() const override;
|
||||
|
||||
caf::PdmFieldHandle* valueField() override { return &m_view; }
|
||||
|
||||
|
||||
@@ -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<caf::PdmOptionItemInfo> RimWorkflowWellPathBinding::calculateValueOptions( const caf::PdmFieldHandle* fieldNeedingOptions )
|
||||
|
||||
@@ -32,6 +32,7 @@ public:
|
||||
RimWorkflowWellPathBinding();
|
||||
|
||||
QString toYamlValue() const override;
|
||||
QString displayValue() const override;
|
||||
|
||||
caf::PdmFieldHandle* valueField() override { return &m_wellPath; }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<RimViewWindow*> 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<RimWorkflow*>( firstSelectedObject ) )
|
||||
{
|
||||
const auto jobs = workflow->jobs();
|
||||
showWorkflowGraph( workflow, jobs.empty() ? nullptr : jobs.front() );
|
||||
}
|
||||
else if ( auto* job = dynamic_cast<RimWorkflowJob*>( firstSelectedObject ) )
|
||||
{
|
||||
if ( auto* workflow = job->firstAncestorOrThisOfType<RimWorkflow>() ) 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<RimWorkflowJob>() != m_displayedWorkflowJob.p() )
|
||||
return;
|
||||
|
||||
if ( auto* task = binding->firstAncestorOrThisOfType<RimWorkflowTaskInput>() )
|
||||
{
|
||||
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 =
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
#include "RiuMainWindowBase.h"
|
||||
|
||||
#include "cafPdmObjectHandle.h"
|
||||
#include "cafPdmPointer.h"
|
||||
|
||||
#include <QEvent>
|
||||
#include <QLabel>
|
||||
@@ -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<QPointer<ads::CDockWidget>> m_additionalProjectViews;
|
||||
QPointer<ads::CDockWidget> m_workflowGraphDock;
|
||||
QPointer<RiuWorkflowGraphView> m_workflowGraphView;
|
||||
caf::PdmPointer<RimWorkflowJob> m_displayedWorkflowJob;
|
||||
};
|
||||
@@ -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 <http://www.gnu.org/licenses/gpl.html> for more details.
|
||||
//
|
||||
/////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
#include "RiuWorkflowGraphView.h"
|
||||
|
||||
#include <QFontMetrics>
|
||||
#include <QGraphicsEllipseItem>
|
||||
#include <QGraphicsPathItem>
|
||||
#include <QGraphicsPolygonItem>
|
||||
#include <QGraphicsRectItem>
|
||||
#include <QGraphicsScene>
|
||||
#include <QGraphicsTextItem>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonValue>
|
||||
#include <QMap>
|
||||
#include <QMouseEvent>
|
||||
#include <QPainter>
|
||||
#include <QPainterPath>
|
||||
#include <QPen>
|
||||
#include <QResizeEvent>
|
||||
#include <QSet>
|
||||
#include <QWheelEvent>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
|
||||
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<QString, QGraphicsEllipseItem*> m_inputs;
|
||||
QMap<QString, QGraphicsEllipseItem*> m_outputs;
|
||||
QMap<QString, QGraphicsTextItem*> m_configValues;
|
||||
std::vector<GraphEdge*> 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<QString, int> ranks;
|
||||
QMap<QString, QStringList> inputs;
|
||||
QMap<QString, QStringList> outputs;
|
||||
QMap<QString, QStringList> declaredOutputs;
|
||||
QMap<QString, QStringList> 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<int, QStringList> layers;
|
||||
QMap<QString, GraphNode*> nodes;
|
||||
QMap<QString, GraphNode*> 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<GraphNode*>( 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<GraphNode*>( 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<GraphNode*>( 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 );
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/gpl.html> for more details.
|
||||
//
|
||||
/////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QGraphicsView>
|
||||
#include <QJsonObject>
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -18,6 +18,18 @@
|
||||
|
||||
#include "RiuWorkflowJobRunner.h"
|
||||
|
||||
#include "RiaQStringFormatter.h"
|
||||
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
|
||||
#include <format>
|
||||
|
||||
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<int, QProcess::ExitStatus>::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 );
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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"}]
|
||||
Reference in new issue
Block a user