diff --git a/ApplicationLibCode/Application/RiaGuiApplication.cpp b/ApplicationLibCode/Application/RiaGuiApplication.cpp index 2fb95e1341..5b4da13f36 100644 --- a/ApplicationLibCode/Application/RiaGuiApplication.cpp +++ b/ApplicationLibCode/Application/RiaGuiApplication.cpp @@ -99,6 +99,7 @@ #include "RimWellPathFracture.h" #include "RimWellPltPlot.h" #include "RimWellRftPlot.h" +#include "Workflow/RimWorkflowCollection.h" #include "Riu3dSelectionManager.h" #include "RiuDockWidgetTools.h" @@ -353,10 +354,27 @@ bool RiaGuiApplication::checkWithUserBeforeClose() if ( !notifyUserAboutRunningJobs() ) return false; // ask user to save modified project if needed if ( !askUserToSaveModifiedProject() ) return false; + if ( !askUserToDiscardWorkflowEdits() ) return false; return true; } +//-------------------------------------------------------------------------------------------------- +/// Workflow edits are not part of the project file; they are lost unless saved to a workflow folder +//-------------------------------------------------------------------------------------------------- +bool RiaGuiApplication::askUserToDiscardWorkflowEdits() +{ + if ( !project() || !project()->workflowCollection() || !project()->workflowCollection()->hasUnsavedChanges() ) return true; + + const auto answer = QMessageBox::question( m_mainWindow, + "Unsaved Workflow Changes", + "Some workflows have changes that are not saved to their workflow folder.\n\n" + "Discard the changes?", + QMessageBox::Discard | QMessageBox::Cancel, + QMessageBox::Cancel ); + return answer == QMessageBox::Discard; +} + //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- diff --git a/ApplicationLibCode/Application/RiaGuiApplication.h b/ApplicationLibCode/Application/RiaGuiApplication.h index d0d0e83a17..2a23cc64b4 100644 --- a/ApplicationLibCode/Application/RiaGuiApplication.h +++ b/ApplicationLibCode/Application/RiaGuiApplication.h @@ -133,6 +133,7 @@ protected: // use the checkWithUserBeforeClose function, to get all checks in one go bool notifyUserAboutRunningJobs(); bool askUserToSaveModifiedProject(); + bool askUserToDiscardWorkflowEdits(); // Protected RiaApplication overrides void invokeProcessEvents( QEventLoop::ProcessEventsFlags flags = QEventLoop::AllEvents ) override; diff --git a/ApplicationLibCode/Commands/WorkflowCommands/CMakeLists_files.cmake b/ApplicationLibCode/Commands/WorkflowCommands/CMakeLists_files.cmake index 951394824a..5540dd50a6 100644 --- a/ApplicationLibCode/Commands/WorkflowCommands/CMakeLists_files.cmake +++ b/ApplicationLibCode/Commands/WorkflowCommands/CMakeLists_files.cmake @@ -2,6 +2,19 @@ set(SOURCE_GROUP_SOURCE_FILES ${CMAKE_CURRENT_LIST_DIR}/RicNewWorkflowJobFeature.cpp ${CMAKE_CURRENT_LIST_DIR}/RicRunWorkflowJobFeature.cpp ${CMAKE_CURRENT_LIST_DIR}/RicCancelWorkflowJobFeature.cpp + ${CMAKE_CURRENT_LIST_DIR}/RicWorkflowLocationUi.cpp + ${CMAKE_CURRENT_LIST_DIR}/RicWorkflowFeatureTools.cpp + ${CMAKE_CURRENT_LIST_DIR}/RicNewWorkflowFeature.cpp + ${CMAKE_CURRENT_LIST_DIR}/RicDuplicateWorkflowAsEditableFeature.cpp + ${CMAKE_CURRENT_LIST_DIR}/RicSaveWorkflowFeature.cpp + ${CMAKE_CURRENT_LIST_DIR}/RicSaveWorkflowAsFeature.cpp + ${CMAKE_CURRENT_LIST_DIR}/RicRescanWorkflowsFeature.cpp + ${CMAKE_CURRENT_LIST_DIR}/RicAddWorkflowTaskFeature.cpp + ${CMAKE_CURRENT_LIST_DIR}/RicDeleteWorkflowTaskFeature.cpp + ${CMAKE_CURRENT_LIST_DIR}/RicDeleteWorkflowConnectionFeature.cpp + ${CMAKE_CURRENT_LIST_DIR}/RicRenameWorkflowTaskFeature.cpp + ${CMAKE_CURRENT_LIST_DIR}/RicSetWorkflowResultTaskFeature.cpp + ${CMAKE_CURRENT_LIST_DIR}/RicToggleWorkflowOptionalInputFeature.cpp ) list(APPEND COMMAND_CODE_SOURCE_FILES ${SOURCE_GROUP_SOURCE_FILES}) diff --git a/ApplicationLibCode/Commands/WorkflowCommands/RicAddWorkflowTaskFeature.cpp b/ApplicationLibCode/Commands/WorkflowCommands/RicAddWorkflowTaskFeature.cpp new file mode 100644 index 0000000000..5741cc804d --- /dev/null +++ b/ApplicationLibCode/Commands/WorkflowCommands/RicAddWorkflowTaskFeature.cpp @@ -0,0 +1,59 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#include "RicAddWorkflowTaskFeature.h" + +#include "RicWorkflowFeatureTools.h" +#include "RiuWorkflowEditorWidget.h" + +#include +#include + +CAF_CMD_SOURCE_INIT( RicAddWorkflowTaskFeature, "RicAddWorkflowTaskFeature" ); + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RicAddWorkflowTaskFeature::isCommandEnabled() const +{ + return RicWorkflowFeatureTools::editableWorkflowEditor() != nullptr; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RicAddWorkflowTaskFeature::onActionTriggered( bool isChecked ) +{ + const QVariantMap data = userData().toMap(); + if ( auto* editor = RicWorkflowFeatureTools::editableWorkflowEditor() ) + { + std::optional position; + if ( data.contains( "x" ) && data.contains( "y" ) ) + position = QPointF( data.value( "x" ).toDouble(), data.value( "y" ).toDouble() ); + editor->addTask( data.value( "taskId" ).toString(), position ); + } +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RicAddWorkflowTaskFeature::setupActionLook( QAction* actionToSetup ) +{ + actionToSetup->setText( "Add Task" ); + actionToSetup->setIcon( QIcon( ":/Plus.png" ) ); +} diff --git a/ApplicationLibCode/Commands/WorkflowCommands/RicAddWorkflowTaskFeature.h b/ApplicationLibCode/Commands/WorkflowCommands/RicAddWorkflowTaskFeature.h new file mode 100644 index 0000000000..9aa19d4701 --- /dev/null +++ b/ApplicationLibCode/Commands/WorkflowCommands/RicAddWorkflowTaskFeature.h @@ -0,0 +1,34 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "cafCmdFeature.h" + +//================================================================================================== +/// Graph command: acts on the workflow shown in the workflow editor, with the item in the user data +//================================================================================================== +class RicAddWorkflowTaskFeature : public caf::CmdFeature +{ + CAF_CMD_HEADER_INIT; + +protected: + bool isCommandEnabled() const override; + void onActionTriggered( bool isChecked ) override; + void setupActionLook( QAction* actionToSetup ) override; +}; diff --git a/ApplicationLibCode/Commands/WorkflowCommands/RicDeleteWorkflowConnectionFeature.cpp b/ApplicationLibCode/Commands/WorkflowCommands/RicDeleteWorkflowConnectionFeature.cpp new file mode 100644 index 0000000000..61af567540 --- /dev/null +++ b/ApplicationLibCode/Commands/WorkflowCommands/RicDeleteWorkflowConnectionFeature.cpp @@ -0,0 +1,58 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#include "RicDeleteWorkflowConnectionFeature.h" + +#include "RicWorkflowFeatureTools.h" +#include "RiuWorkflowEditorWidget.h" + +#include + +CAF_CMD_SOURCE_INIT( RicDeleteWorkflowConnectionFeature, "RicDeleteWorkflowConnectionFeature" ); + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RicDeleteWorkflowConnectionFeature::isCommandEnabled() const +{ + return RicWorkflowFeatureTools::editableWorkflowEditor() != nullptr; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RicDeleteWorkflowConnectionFeature::onActionTriggered( bool isChecked ) +{ + const QVariantMap data = userData().toMap(); + if ( auto* editor = RicWorkflowFeatureTools::editableWorkflowEditor() ) + { + editor->deleteItem( RiuWorkflowGraphView::ItemRef::forEdge( data.value( "from" ).toString(), + data.value( "output" ).toString(), + data.value( "to" ).toString(), + data.value( "input" ).toString() ) ); + } +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RicDeleteWorkflowConnectionFeature::setupActionLook( QAction* actionToSetup ) +{ + actionToSetup->setText( "Delete Connection" ); + actionToSetup->setIcon( QIcon( ":/Erase.svg" ) ); +} diff --git a/ApplicationLibCode/Commands/WorkflowCommands/RicDeleteWorkflowConnectionFeature.h b/ApplicationLibCode/Commands/WorkflowCommands/RicDeleteWorkflowConnectionFeature.h new file mode 100644 index 0000000000..e7789c071b --- /dev/null +++ b/ApplicationLibCode/Commands/WorkflowCommands/RicDeleteWorkflowConnectionFeature.h @@ -0,0 +1,34 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "cafCmdFeature.h" + +//================================================================================================== +/// Graph command: acts on the workflow shown in the workflow editor, with the item in the user data +//================================================================================================== +class RicDeleteWorkflowConnectionFeature : public caf::CmdFeature +{ + CAF_CMD_HEADER_INIT; + +protected: + bool isCommandEnabled() const override; + void onActionTriggered( bool isChecked ) override; + void setupActionLook( QAction* actionToSetup ) override; +}; diff --git a/ApplicationLibCode/Commands/WorkflowCommands/RicDeleteWorkflowTaskFeature.cpp b/ApplicationLibCode/Commands/WorkflowCommands/RicDeleteWorkflowTaskFeature.cpp new file mode 100644 index 0000000000..6bc96a2c9d --- /dev/null +++ b/ApplicationLibCode/Commands/WorkflowCommands/RicDeleteWorkflowTaskFeature.cpp @@ -0,0 +1,55 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#include "RicDeleteWorkflowTaskFeature.h" + +#include "RicWorkflowFeatureTools.h" +#include "RiuWorkflowEditorWidget.h" + +#include + +CAF_CMD_SOURCE_INIT( RicDeleteWorkflowTaskFeature, "RicDeleteWorkflowTaskFeature" ); + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RicDeleteWorkflowTaskFeature::isCommandEnabled() const +{ + return RicWorkflowFeatureTools::editableWorkflowEditor() != nullptr; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RicDeleteWorkflowTaskFeature::onActionTriggered( bool isChecked ) +{ + const QVariantMap data = userData().toMap(); + if ( auto* editor = RicWorkflowFeatureTools::editableWorkflowEditor() ) + { + editor->deleteItem( RiuWorkflowGraphView::ItemRef::forTask( data.value( "task" ).toString() ) ); + } +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RicDeleteWorkflowTaskFeature::setupActionLook( QAction* actionToSetup ) +{ + actionToSetup->setText( "Delete Task" ); + actionToSetup->setIcon( QIcon( ":/Erase.svg" ) ); +} diff --git a/ApplicationLibCode/Commands/WorkflowCommands/RicDeleteWorkflowTaskFeature.h b/ApplicationLibCode/Commands/WorkflowCommands/RicDeleteWorkflowTaskFeature.h new file mode 100644 index 0000000000..00cf47094e --- /dev/null +++ b/ApplicationLibCode/Commands/WorkflowCommands/RicDeleteWorkflowTaskFeature.h @@ -0,0 +1,34 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "cafCmdFeature.h" + +//================================================================================================== +/// Graph command: acts on the workflow shown in the workflow editor, with the item in the user data +//================================================================================================== +class RicDeleteWorkflowTaskFeature : public caf::CmdFeature +{ + CAF_CMD_HEADER_INIT; + +protected: + bool isCommandEnabled() const override; + void onActionTriggered( bool isChecked ) override; + void setupActionLook( QAction* actionToSetup ) override; +}; diff --git a/ApplicationLibCode/Commands/WorkflowCommands/RicDuplicateWorkflowAsEditableFeature.cpp b/ApplicationLibCode/Commands/WorkflowCommands/RicDuplicateWorkflowAsEditableFeature.cpp new file mode 100644 index 0000000000..25a1bbbbfb --- /dev/null +++ b/ApplicationLibCode/Commands/WorkflowCommands/RicDuplicateWorkflowAsEditableFeature.cpp @@ -0,0 +1,52 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#include "RicDuplicateWorkflowAsEditableFeature.h" + +#include "RicWorkflowFeatureTools.h" +#include "Workflow/RimWorkflow.h" + +#include + +CAF_CMD_SOURCE_INIT( RicDuplicateWorkflowAsEditableFeature, "RicDuplicateWorkflowAsEditableFeature" ); + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RicDuplicateWorkflowAsEditableFeature::isCommandEnabled() const +{ + auto* workflow = RicWorkflowFeatureTools::selectedWorkflow(); + return workflow && workflow->loadError().isEmpty() && workflow->definition().editable; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RicDuplicateWorkflowAsEditableFeature::onActionTriggered( bool isChecked ) +{ + RicWorkflowFeatureTools::duplicateAsEditable( RicWorkflowFeatureTools::selectedWorkflow() ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RicDuplicateWorkflowAsEditableFeature::setupActionLook( QAction* actionToSetup ) +{ + actionToSetup->setText( "Duplicate as Editable..." ); + actionToSetup->setIcon( QIcon( ":/Copy.svg" ) ); +} diff --git a/ApplicationLibCode/Commands/WorkflowCommands/RicDuplicateWorkflowAsEditableFeature.h b/ApplicationLibCode/Commands/WorkflowCommands/RicDuplicateWorkflowAsEditableFeature.h new file mode 100644 index 0000000000..0d37d06e63 --- /dev/null +++ b/ApplicationLibCode/Commands/WorkflowCommands/RicDuplicateWorkflowAsEditableFeature.h @@ -0,0 +1,34 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "cafCmdFeature.h" + +//================================================================================================== +/// +//================================================================================================== +class RicDuplicateWorkflowAsEditableFeature : public caf::CmdFeature +{ + CAF_CMD_HEADER_INIT; + +protected: + bool isCommandEnabled() const override; + void onActionTriggered( bool isChecked ) override; + void setupActionLook( QAction* actionToSetup ) override; +}; diff --git a/ApplicationLibCode/Commands/WorkflowCommands/RicNewWorkflowFeature.cpp b/ApplicationLibCode/Commands/WorkflowCommands/RicNewWorkflowFeature.cpp new file mode 100644 index 0000000000..8761d0a402 --- /dev/null +++ b/ApplicationLibCode/Commands/WorkflowCommands/RicNewWorkflowFeature.cpp @@ -0,0 +1,52 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#include "RicNewWorkflowFeature.h" + +#include "RicWorkflowFeatureTools.h" +#include "RimProject.h" +#include "Workflow/RimWorkflowCollection.h" + +#include + +CAF_CMD_SOURCE_INIT( RicNewWorkflowFeature, "RicNewWorkflowFeature" ); + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RicNewWorkflowFeature::isCommandEnabled() const +{ + return RimProject::current() && RimProject::current()->workflowCollection(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RicNewWorkflowFeature::onActionTriggered( bool isChecked ) +{ + RicWorkflowFeatureTools::newWorkflow(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RicNewWorkflowFeature::setupActionLook( QAction* actionToSetup ) +{ + actionToSetup->setText( "New Workflow..." ); + actionToSetup->setIcon( QIcon( ":/Plus.png" ) ); +} diff --git a/ApplicationLibCode/Commands/WorkflowCommands/RicNewWorkflowFeature.h b/ApplicationLibCode/Commands/WorkflowCommands/RicNewWorkflowFeature.h new file mode 100644 index 0000000000..8cb7df59fe --- /dev/null +++ b/ApplicationLibCode/Commands/WorkflowCommands/RicNewWorkflowFeature.h @@ -0,0 +1,34 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "cafCmdFeature.h" + +//================================================================================================== +/// +//================================================================================================== +class RicNewWorkflowFeature : public caf::CmdFeature +{ + CAF_CMD_HEADER_INIT; + +protected: + bool isCommandEnabled() const override; + void onActionTriggered( bool isChecked ) override; + void setupActionLook( QAction* actionToSetup ) override; +}; diff --git a/ApplicationLibCode/Commands/WorkflowCommands/RicRenameWorkflowTaskFeature.cpp b/ApplicationLibCode/Commands/WorkflowCommands/RicRenameWorkflowTaskFeature.cpp new file mode 100644 index 0000000000..cb1f02f5ac --- /dev/null +++ b/ApplicationLibCode/Commands/WorkflowCommands/RicRenameWorkflowTaskFeature.cpp @@ -0,0 +1,51 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#include "RicRenameWorkflowTaskFeature.h" + +#include "RicWorkflowFeatureTools.h" +#include "RiuWorkflowEditorWidget.h" + +#include + +CAF_CMD_SOURCE_INIT( RicRenameWorkflowTaskFeature, "RicRenameWorkflowTaskFeature" ); + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RicRenameWorkflowTaskFeature::isCommandEnabled() const +{ + return RicWorkflowFeatureTools::editableWorkflowEditor() != nullptr; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RicRenameWorkflowTaskFeature::onActionTriggered( bool isChecked ) +{ + const QVariantMap data = userData().toMap(); + if ( auto* editor = RicWorkflowFeatureTools::editableWorkflowEditor() ) editor->renameTask( data.value( "task" ).toString() ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RicRenameWorkflowTaskFeature::setupActionLook( QAction* actionToSetup ) +{ + actionToSetup->setText( "Rename..." ); +} diff --git a/ApplicationLibCode/Commands/WorkflowCommands/RicRenameWorkflowTaskFeature.h b/ApplicationLibCode/Commands/WorkflowCommands/RicRenameWorkflowTaskFeature.h new file mode 100644 index 0000000000..ee2fa83413 --- /dev/null +++ b/ApplicationLibCode/Commands/WorkflowCommands/RicRenameWorkflowTaskFeature.h @@ -0,0 +1,34 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "cafCmdFeature.h" + +//================================================================================================== +/// Graph command: acts on the workflow shown in the workflow editor, with the item in the user data +//================================================================================================== +class RicRenameWorkflowTaskFeature : public caf::CmdFeature +{ + CAF_CMD_HEADER_INIT; + +protected: + bool isCommandEnabled() const override; + void onActionTriggered( bool isChecked ) override; + void setupActionLook( QAction* actionToSetup ) override; +}; diff --git a/ApplicationLibCode/Commands/WorkflowCommands/RicRescanWorkflowsFeature.cpp b/ApplicationLibCode/Commands/WorkflowCommands/RicRescanWorkflowsFeature.cpp new file mode 100644 index 0000000000..0c9b00191b --- /dev/null +++ b/ApplicationLibCode/Commands/WorkflowCommands/RicRescanWorkflowsFeature.cpp @@ -0,0 +1,61 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RicRescanWorkflowsFeature.h" + +#include "RicWorkflowFeatureTools.h" +#include "RimProject.h" +#include "Workflow/RimWorkflow.h" +#include "Workflow/RimWorkflowCollection.h" + +#include + +CAF_CMD_SOURCE_INIT( RicRescanWorkflowsFeature, "RicRescanWorkflowsFeature" ); + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RicRescanWorkflowsFeature::isCommandEnabled() const +{ + RimProject* project = RimProject::current(); + if ( !project || !project->workflowCollection() ) return false; + + // Rescanning deletes the workflows, including running jobs + for ( RimWorkflow* workflow : project->workflowCollection()->allWorkflows() ) + { + if ( workflow->isLocked() ) return false; + } + return true; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RicRescanWorkflowsFeature::onActionTriggered( bool isChecked ) +{ + RicWorkflowFeatureTools::rescanWorkflows(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RicRescanWorkflowsFeature::setupActionLook( QAction* actionToSetup ) +{ + actionToSetup->setText( "Rescan Workflows" ); + actionToSetup->setIcon( QIcon( ":/Refresh.svg" ) ); +} diff --git a/ApplicationLibCode/Commands/WorkflowCommands/RicRescanWorkflowsFeature.h b/ApplicationLibCode/Commands/WorkflowCommands/RicRescanWorkflowsFeature.h new file mode 100644 index 0000000000..c374185897 --- /dev/null +++ b/ApplicationLibCode/Commands/WorkflowCommands/RicRescanWorkflowsFeature.h @@ -0,0 +1,34 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "cafCmdFeature.h" + +//================================================================================================== +/// +//================================================================================================== +class RicRescanWorkflowsFeature : public caf::CmdFeature +{ + CAF_CMD_HEADER_INIT; + +protected: + bool isCommandEnabled() const override; + void onActionTriggered( bool isChecked ) override; + void setupActionLook( QAction* actionToSetup ) override; +}; diff --git a/ApplicationLibCode/Commands/WorkflowCommands/RicRunWorkflowJobFeature.cpp b/ApplicationLibCode/Commands/WorkflowCommands/RicRunWorkflowJobFeature.cpp index f263121430..46c5be5993 100644 --- a/ApplicationLibCode/Commands/WorkflowCommands/RicRunWorkflowJobFeature.cpp +++ b/ApplicationLibCode/Commands/WorkflowCommands/RicRunWorkflowJobFeature.cpp @@ -18,11 +18,15 @@ #include "RicRunWorkflowJobFeature.h" +#include "Workflow/RimWorkflow.h" #include "Workflow/RimWorkflowJob.h" +#include "Riu3DMainWindowTools.h" + #include "cafSelectionManagerTools.h" #include +#include CAF_CMD_SOURCE_INIT( RicRunWorkflowJobFeature, "RicRunWorkflowJobFeature" ); @@ -33,6 +37,17 @@ void RicRunWorkflowJobFeature::onActionTriggered( bool isChecked ) { auto jobs = caf::selectedObjectsByType(); if ( jobs.size() != 1 ) return; + + auto* workflow = jobs.front()->firstAncestorOrThisOfType(); + if ( workflow && workflow->isEditable() && workflow->lastValidationFailed() ) + { + const auto answer = QMessageBox::question( Riu3DMainWindowTools::mainWindowWidget(), + "Run Workflow", + "The last validation of the workflow reported problems. Run anyway?", + QMessageBox::Yes | QMessageBox::No, + QMessageBox::No ); + if ( answer != QMessageBox::Yes ) return; + } jobs.front()->runJob(); } diff --git a/ApplicationLibCode/Commands/WorkflowCommands/RicSaveWorkflowAsFeature.cpp b/ApplicationLibCode/Commands/WorkflowCommands/RicSaveWorkflowAsFeature.cpp new file mode 100644 index 0000000000..f78245ac2b --- /dev/null +++ b/ApplicationLibCode/Commands/WorkflowCommands/RicSaveWorkflowAsFeature.cpp @@ -0,0 +1,52 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#include "RicSaveWorkflowAsFeature.h" + +#include "RicWorkflowFeatureTools.h" +#include "Workflow/RimWorkflow.h" + +#include + +CAF_CMD_SOURCE_INIT( RicSaveWorkflowAsFeature, "RicSaveWorkflowAsFeature" ); + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RicSaveWorkflowAsFeature::isCommandEnabled() const +{ + auto* workflow = RicWorkflowFeatureTools::selectedWorkflow(); + return workflow && workflow->isEditable() && !workflow->isLocked(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RicSaveWorkflowAsFeature::onActionTriggered( bool isChecked ) +{ + RicWorkflowFeatureTools::saveWorkflowAs( RicWorkflowFeatureTools::selectedWorkflow() ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RicSaveWorkflowAsFeature::setupActionLook( QAction* actionToSetup ) +{ + actionToSetup->setText( "Save Workflow As..." ); + actionToSetup->setIcon( QIcon( ":/SaveAs.svg" ) ); +} diff --git a/ApplicationLibCode/Commands/WorkflowCommands/RicSaveWorkflowAsFeature.h b/ApplicationLibCode/Commands/WorkflowCommands/RicSaveWorkflowAsFeature.h new file mode 100644 index 0000000000..83236676ff --- /dev/null +++ b/ApplicationLibCode/Commands/WorkflowCommands/RicSaveWorkflowAsFeature.h @@ -0,0 +1,34 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "cafCmdFeature.h" + +//================================================================================================== +/// +//================================================================================================== +class RicSaveWorkflowAsFeature : public caf::CmdFeature +{ + CAF_CMD_HEADER_INIT; + +protected: + bool isCommandEnabled() const override; + void onActionTriggered( bool isChecked ) override; + void setupActionLook( QAction* actionToSetup ) override; +}; diff --git a/ApplicationLibCode/Commands/WorkflowCommands/RicSaveWorkflowFeature.cpp b/ApplicationLibCode/Commands/WorkflowCommands/RicSaveWorkflowFeature.cpp new file mode 100644 index 0000000000..ff0af19ffc --- /dev/null +++ b/ApplicationLibCode/Commands/WorkflowCommands/RicSaveWorkflowFeature.cpp @@ -0,0 +1,52 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#include "RicSaveWorkflowFeature.h" + +#include "RicWorkflowFeatureTools.h" +#include "Workflow/RimWorkflow.h" + +#include + +CAF_CMD_SOURCE_INIT( RicSaveWorkflowFeature, "RicSaveWorkflowFeature" ); + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RicSaveWorkflowFeature::isCommandEnabled() const +{ + auto* workflow = RicWorkflowFeatureTools::selectedWorkflow(); + return workflow && workflow->isEditable() && !workflow->isLocked() && workflow->isDirty(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RicSaveWorkflowFeature::onActionTriggered( bool isChecked ) +{ + RicWorkflowFeatureTools::saveWorkflow( RicWorkflowFeatureTools::selectedWorkflow() ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RicSaveWorkflowFeature::setupActionLook( QAction* actionToSetup ) +{ + actionToSetup->setText( "Save Workflow" ); + actionToSetup->setIcon( QIcon( ":/Save.svg" ) ); +} diff --git a/ApplicationLibCode/Commands/WorkflowCommands/RicSaveWorkflowFeature.h b/ApplicationLibCode/Commands/WorkflowCommands/RicSaveWorkflowFeature.h new file mode 100644 index 0000000000..e9fa869da4 --- /dev/null +++ b/ApplicationLibCode/Commands/WorkflowCommands/RicSaveWorkflowFeature.h @@ -0,0 +1,34 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "cafCmdFeature.h" + +//================================================================================================== +/// +//================================================================================================== +class RicSaveWorkflowFeature : public caf::CmdFeature +{ + CAF_CMD_HEADER_INIT; + +protected: + bool isCommandEnabled() const override; + void onActionTriggered( bool isChecked ) override; + void setupActionLook( QAction* actionToSetup ) override; +}; diff --git a/ApplicationLibCode/Commands/WorkflowCommands/RicSetWorkflowResultTaskFeature.cpp b/ApplicationLibCode/Commands/WorkflowCommands/RicSetWorkflowResultTaskFeature.cpp new file mode 100644 index 0000000000..2d4662914e --- /dev/null +++ b/ApplicationLibCode/Commands/WorkflowCommands/RicSetWorkflowResultTaskFeature.cpp @@ -0,0 +1,51 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#include "RicSetWorkflowResultTaskFeature.h" + +#include "RicWorkflowFeatureTools.h" +#include "RiuWorkflowEditorWidget.h" + +#include + +CAF_CMD_SOURCE_INIT( RicSetWorkflowResultTaskFeature, "RicSetWorkflowResultTaskFeature" ); + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RicSetWorkflowResultTaskFeature::isCommandEnabled() const +{ + return RicWorkflowFeatureTools::editableWorkflowEditor() != nullptr; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RicSetWorkflowResultTaskFeature::onActionTriggered( bool isChecked ) +{ + const QVariantMap data = userData().toMap(); + if ( auto* editor = RicWorkflowFeatureTools::editableWorkflowEditor() ) editor->setResultTask( data.value( "task" ).toString() ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RicSetWorkflowResultTaskFeature::setupActionLook( QAction* actionToSetup ) +{ + actionToSetup->setText( "Set as Result Task" ); +} diff --git a/ApplicationLibCode/Commands/WorkflowCommands/RicSetWorkflowResultTaskFeature.h b/ApplicationLibCode/Commands/WorkflowCommands/RicSetWorkflowResultTaskFeature.h new file mode 100644 index 0000000000..11e68a8910 --- /dev/null +++ b/ApplicationLibCode/Commands/WorkflowCommands/RicSetWorkflowResultTaskFeature.h @@ -0,0 +1,34 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "cafCmdFeature.h" + +//================================================================================================== +/// Graph command: acts on the workflow shown in the workflow editor, with the item in the user data +//================================================================================================== +class RicSetWorkflowResultTaskFeature : public caf::CmdFeature +{ + CAF_CMD_HEADER_INIT; + +protected: + bool isCommandEnabled() const override; + void onActionTriggered( bool isChecked ) override; + void setupActionLook( QAction* actionToSetup ) override; +}; diff --git a/ApplicationLibCode/Commands/WorkflowCommands/RicToggleWorkflowOptionalInputFeature.cpp b/ApplicationLibCode/Commands/WorkflowCommands/RicToggleWorkflowOptionalInputFeature.cpp new file mode 100644 index 0000000000..b898f28361 --- /dev/null +++ b/ApplicationLibCode/Commands/WorkflowCommands/RicToggleWorkflowOptionalInputFeature.cpp @@ -0,0 +1,54 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RicToggleWorkflowOptionalInputFeature.h" + +#include "RicWorkflowFeatureTools.h" +#include "RiuWorkflowEditorWidget.h" + +#include + +CAF_CMD_SOURCE_INIT( RicToggleWorkflowOptionalInputFeature, "RicToggleWorkflowOptionalInputFeature" ); + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RicToggleWorkflowOptionalInputFeature::isCommandEnabled() const +{ + return RicWorkflowFeatureTools::editableWorkflowEditor() != nullptr; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RicToggleWorkflowOptionalInputFeature::onActionTriggered( bool isChecked ) +{ + const QVariantMap data = userData().toMap(); + if ( auto* editor = RicWorkflowFeatureTools::editableWorkflowEditor() ) + editor->setOptionalInputConfigured( data.value( "task" ).toString(), + data.value( "field" ).toString(), + data.value( "configured" ).toBool() ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RicToggleWorkflowOptionalInputFeature::setupActionLook( QAction* actionToSetup ) +{ + actionToSetup->setText( "Configure Optional Input" ); +} diff --git a/ApplicationLibCode/Commands/WorkflowCommands/RicToggleWorkflowOptionalInputFeature.h b/ApplicationLibCode/Commands/WorkflowCommands/RicToggleWorkflowOptionalInputFeature.h new file mode 100644 index 0000000000..68e1f56afe --- /dev/null +++ b/ApplicationLibCode/Commands/WorkflowCommands/RicToggleWorkflowOptionalInputFeature.h @@ -0,0 +1,34 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "cafCmdFeature.h" + +//================================================================================================== +/// Graph command: acts on the workflow shown in the workflow editor, with the item in the user data +//================================================================================================== +class RicToggleWorkflowOptionalInputFeature : public caf::CmdFeature +{ + CAF_CMD_HEADER_INIT; + +protected: + bool isCommandEnabled() const override; + void onActionTriggered( bool isChecked ) override; + void setupActionLook( QAction* actionToSetup ) override; +}; diff --git a/ApplicationLibCode/Commands/WorkflowCommands/RicWorkflowFeatureTools.cpp b/ApplicationLibCode/Commands/WorkflowCommands/RicWorkflowFeatureTools.cpp new file mode 100644 index 0000000000..47b0d86ff2 --- /dev/null +++ b/ApplicationLibCode/Commands/WorkflowCommands/RicWorkflowFeatureTools.cpp @@ -0,0 +1,246 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RicWorkflowFeatureTools.h" + +#include "RicWorkflowLocationUi.h" + +#include "RimProject.h" +#include "Workflow/RimWorkflow.h" +#include "Workflow/RimWorkflowCollection.h" +#include "Workflow/RimWorkflowDefinitionTools.h" + +#include "Riu3DMainWindowTools.h" +#include "RiuMainWindow.h" +#include "RiuWorkflowEditorWidget.h" +#include "RiuWorkflowTaskPalette.h" + +#include "cafSelectionManagerTools.h" + +#include + +namespace +{ +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RimWorkflowCollection* workflowCollection() +{ + RimProject* project = RimProject::current(); + return project ? project->workflowCollection() : nullptr; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void addAndSelect( RimWorkflow* workflow ) +{ + RimWorkflowCollection* collection = workflowCollection(); + collection->addItem( workflow ); + collection->updateConnectedEditors(); + Riu3DMainWindowTools::selectAsCurrentItem( workflow ); +} +} // namespace + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RimWorkflow* RicWorkflowFeatureTools::selectedWorkflow() +{ + if ( auto* workflow = caf::firstAncestorOfTypeFromSelectedObject() ) return workflow; + return displayedWorkflow(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RimWorkflow* RicWorkflowFeatureTools::displayedWorkflow() +{ + auto* window = RiuMainWindow::instance(); + return window ? window->displayedWorkflow() : nullptr; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RiuWorkflowEditorWidget* RicWorkflowFeatureTools::workflowEditor() +{ + auto* window = RiuMainWindow::instance(); + return window ? window->workflowEditor() : nullptr; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RiuWorkflowEditorWidget* RicWorkflowFeatureTools::editableWorkflowEditor() +{ + RiuWorkflowEditorWidget* editor = workflowEditor(); + if ( !editor || !editor->workflow() ) return nullptr; + if ( !editor->workflow()->isEditable() || editor->workflow()->isLocked() ) return nullptr; + return editor; +} + +//-------------------------------------------------------------------------------------------------- +/// An empty workflow, saved to its folder on the first Save +//-------------------------------------------------------------------------------------------------- +RimWorkflow* RicWorkflowFeatureTools::newWorkflow() +{ + if ( !workflowCollection() ) return nullptr; + + const auto location = RicWorkflowLocationUi::askForLocation( "New Workflow", "new_workflow" ); + if ( !location ) return nullptr; + + auto* workflow = new RimWorkflow; + workflow->setUnsavedDefinition( RimWorkflowDefinitionTools::createEmpty( location->name ), {}, location->folder ); + addAndSelect( workflow ); + return workflow; +} + +//-------------------------------------------------------------------------------------------------- +/// An editable copy with the values of the selected job. Python modules next to the source YAML +/// are copied on the first Save. +//-------------------------------------------------------------------------------------------------- +RimWorkflow* RicWorkflowFeatureTools::duplicateAsEditable( RimWorkflow* workflow ) +{ + if ( !workflow || !workflowCollection() ) return nullptr; + if ( !workflow->loadError().isEmpty() ) + { + showError( "Duplicate Workflow", workflow->loadError() ); + return nullptr; + } + if ( !workflow->definition().editable ) + { + showError( "Duplicate Workflow", + QString( "An editable copy is not possible:\n%1" ).arg( workflow->definition().readOnlyReasons.join( "\n" ) ) ); + return nullptr; + } + + const auto location = RicWorkflowLocationUi::askForLocation( "Duplicate as Editable", workflow->name() + "_copy" ); + if ( !location ) return nullptr; + + RimWorkflowDefinition copy = RimWorkflowDefinitionTools::editableCopy( workflow->definition(), location->name ); + const QJsonObject values = workflow->inputValuesForSave(); + for ( auto it = values.begin(); it != values.end(); ++it ) + copy.inputs[it.key()] = it.value(); + + auto* duplicate = new RimWorkflow; + duplicate->setUnsavedDefinition( copy, workflow->sourceDirectory(), location->folder ); + addAndSelect( duplicate ); + if ( !duplicate->isEditable() ) + showError( "Duplicate Workflow", QString( "The copy cannot be edited: %1" ).arg( duplicate->readOnlyReason() ) ); + return duplicate; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RicWorkflowFeatureTools::saveWorkflow( RimWorkflow* workflow ) +{ + if ( !workflow || !workflow->isEditable() ) return false; + if ( workflow->source() == RimWorkflow::Source::Unsaved && workflow->workflowDirectory().isEmpty() ) return saveWorkflowAs( workflow ); + + const QStringList warnings = workflow->saveInPlaceWarnings(); + if ( !warnings.isEmpty() ) + { + const auto answer = QMessageBox::question( Riu3DMainWindowTools::mainWindowWidget(), + "Save Workflow", + warnings.join( "\n\n" ) + "\n\nSave anyway?", + QMessageBox::Save | QMessageBox::Cancel, + QMessageBox::Cancel ); + if ( answer != QMessageBox::Save ) return false; + } + + auto result = workflow->save(); + if ( !result ) + { + showError( "Save Workflow", result.error() ); + return false; + } + workflow->updateConnectedEditors(); + return true; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RicWorkflowFeatureTools::saveWorkflowAs( RimWorkflow* workflow ) +{ + if ( !workflow || !workflow->isEditable() ) return false; + + const auto location = RicWorkflowLocationUi::askForLocation( "Save Workflow As", workflow->name() ); + if ( !location ) return false; + + if ( location->name != workflow->name() ) + { + auto renamed = workflow->applyEdit( RimWorkflowDefinitionTools::renameWorkflow( workflow->definition(), location->name ) ); + if ( !renamed ) + { + showError( "Save Workflow As", renamed.error() ); + return false; + } + } + + auto result = workflow->saveAs( location->folder ); + if ( !result ) + { + showError( "Save Workflow As", result.error() ); + return false; + } + workflow->updateConnectedEditors(); + return true; +} + +//-------------------------------------------------------------------------------------------------- +/// Reload the task catalog and the workflow folders. Unsaved edits of saved workflows are lost. +//-------------------------------------------------------------------------------------------------- +bool RicWorkflowFeatureTools::rescanWorkflows() +{ + RimWorkflowCollection* collection = workflowCollection(); + if ( !collection ) return false; + + QStringList dirtyNames; + for ( RimWorkflow* workflow : collection->items() ) + { + if ( workflow->source() != RimWorkflow::Source::Unsaved && workflow->isEditable() && workflow->isDirty() ) + dirtyNames << workflow->name(); + } + if ( !dirtyNames.isEmpty() ) + { + const auto answer = + QMessageBox::question( Riu3DMainWindowTools::mainWindowWidget(), + "Rescan Workflows", + QString( "Rescanning discards the unsaved changes of: %1\n\nContinue?" ).arg( dirtyNames.join( ", " ) ), + QMessageBox::Discard | QMessageBox::Cancel, + QMessageBox::Cancel ); + if ( answer != QMessageBox::Discard ) return false; + } + + collection->rescanWorkflows(); + + if ( auto* window = RiuMainWindow::instance() ) window->workflowDefinitionChanged( nullptr ); + if ( auto* editor = workflowEditor() ) editor->palette()->reload(); + return true; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RicWorkflowFeatureTools::showError( const QString& title, const QString& message ) +{ + QMessageBox::warning( Riu3DMainWindowTools::mainWindowWidget(), title, message ); +} diff --git a/ApplicationLibCode/Commands/WorkflowCommands/RicWorkflowFeatureTools.h b/ApplicationLibCode/Commands/WorkflowCommands/RicWorkflowFeatureTools.h new file mode 100644 index 0000000000..a03d68642b --- /dev/null +++ b/ApplicationLibCode/Commands/WorkflowCommands/RicWorkflowFeatureTools.h @@ -0,0 +1,47 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include + +class RimWorkflow; +class RiuWorkflowEditorWidget; + +//================================================================================================== +/// Shared code of the workflow commands and the workflow editor toolbar +//================================================================================================== +namespace RicWorkflowFeatureTools +{ +// The workflow of the selected tree item, else the workflow shown in the editor +RimWorkflow* selectedWorkflow(); +RimWorkflow* displayedWorkflow(); +RiuWorkflowEditorWidget* workflowEditor(); + +// The editor when it shows an editable, unlocked workflow; graph commands act on it +RiuWorkflowEditorWidget* editableWorkflowEditor(); + +RimWorkflow* newWorkflow(); +RimWorkflow* duplicateAsEditable( RimWorkflow* workflow ); +bool saveWorkflow( RimWorkflow* workflow ); +bool saveWorkflowAs( RimWorkflow* workflow ); +bool rescanWorkflows(); + +void showError( const QString& title, const QString& message ); +} // namespace RicWorkflowFeatureTools diff --git a/ApplicationLibCode/Commands/WorkflowCommands/RicWorkflowLocationUi.cpp b/ApplicationLibCode/Commands/WorkflowCommands/RicWorkflowLocationUi.cpp new file mode 100644 index 0000000000..f3c5ab8194 --- /dev/null +++ b/ApplicationLibCode/Commands/WorkflowCommands/RicWorkflowLocationUi.cpp @@ -0,0 +1,140 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RicWorkflowLocationUi.h" + +#include "Workflow/RimWorkflowCollection.h" +#include "Workflow/RimWorkflowDefinitionTools.h" + +#include "Riu3DMainWindowTools.h" + +#include "cafPdmUiFilePathEditor.h" +#include "cafPdmUiPropertyViewDialog.h" + +#include +#include + +CAF_PDM_SOURCE_INIT( RicWorkflowLocationUi, "RicWorkflowLocationUi" ); + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RicWorkflowLocationUi::RicWorkflowLocationUi() +{ + CAF_PDM_InitObject( "Workflow Location" ); + + CAF_PDM_InitFieldNoDefault( &m_name, "Name", "Name" ); + CAF_PDM_InitFieldNoDefault( &m_folder, "Folder", "Folder" ); + m_folder.uiCapability()->setUiEditorTypeName( caf::PdmUiFilePathEditor::uiEditorTypeName() ); + m_folder.uiCapability()->setAttribute( caf::PdmUiFilePathEditor::Keys::SELECT_DIRECTORY, true ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RicWorkflowLocationUi::setName( const QString& name ) +{ + m_name = name; + if ( !m_folderEditedByUser ) m_folder = defaultFolder( name ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QString RicWorkflowLocationUi::name() const +{ + return m_name().trimmed(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QString RicWorkflowLocationUi::folder() const +{ + return m_folder().path().trimmed(); +} + +//-------------------------------------------------------------------------------------------------- +/// A folder in the discovery folder named after the workflow +//-------------------------------------------------------------------------------------------------- +QString RicWorkflowLocationUi::defaultFolder( const QString& name ) +{ + return QDir( RimWorkflowCollection::discoveryDirectory() ).absoluteFilePath( RimWorkflowDefinitionTools::identifierFrom( name ) ); +} + +//-------------------------------------------------------------------------------------------------- +/// Ask for a name and a folder. Returns nothing when cancelled. Warns before using a folder that +/// already holds a workflow, or a folder that will not be found by Rescan. +//-------------------------------------------------------------------------------------------------- +std::optional RicWorkflowLocationUi::askForLocation( const QString& title, const QString& defaultName ) +{ + RicWorkflowLocationUi ui; + ui.setName( defaultName ); + + QWidget* parent = Riu3DMainWindowTools::mainWindowWidget(); + while ( true ) + { + caf::PdmUiPropertyViewDialog dialog( parent, &ui, title, "", QDialogButtonBox::Ok | QDialogButtonBox::Cancel ); + dialog.resize( QSize( 500, 150 ) ); + if ( dialog.exec() != QDialog::Accepted ) return std::nullopt; + + if ( ui.name().isEmpty() || ui.folder().isEmpty() ) + { + QMessageBox::warning( parent, title, "Enter both a name and a folder." ); + continue; + } + + const QDir folder( ui.folder() ); + if ( folder.exists( "workflow.yaml" ) ) + { + const auto answer = + QMessageBox::question( parent, + title, + QString( "'%1' already contains a workflow. Overwrite it when saving?" ).arg( ui.folder() ) ); + if ( answer != QMessageBox::Yes ) continue; + } + + const QString discovery = QDir( RimWorkflowCollection::discoveryDirectory() ).absolutePath(); + if ( QDir( folder.absolutePath() + "/.." ).absolutePath() != discovery ) + { + const auto answer = QMessageBox::question( parent, + title, + QString( "'%1' is not directly inside %2, so the workflow will not be listed " + "after Rescan or a restart. Use it anyway?" ) + .arg( ui.folder(), discovery ) ); + if ( answer != QMessageBox::Yes ) continue; + } + + return Location{ .name = ui.name(), .folder = folder.absolutePath() }; + } +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RicWorkflowLocationUi::fieldChangedByUi( const caf::PdmFieldHandle* changedField, const QVariant& oldValue, const QVariant& newValue ) +{ + if ( changedField == &m_name ) + { + if ( !m_folderEditedByUser ) m_folder = defaultFolder( m_name() ); + } + else if ( changedField == &m_folder ) + { + m_folderEditedByUser = true; + } +} diff --git a/ApplicationLibCode/Commands/WorkflowCommands/RicWorkflowLocationUi.h b/ApplicationLibCode/Commands/WorkflowCommands/RicWorkflowLocationUi.h new file mode 100644 index 0000000000..f6b6cab19b --- /dev/null +++ b/ApplicationLibCode/Commands/WorkflowCommands/RicWorkflowLocationUi.h @@ -0,0 +1,58 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "cafFilePath.h" +#include "cafPdmField.h" +#include "cafPdmObject.h" + +#include + +//================================================================================================== +/// Name and folder of a workflow, asked for by New, Duplicate as Editable and Save As. +/// The folder follows the name until the user edits the folder. +//================================================================================================== +class RicWorkflowLocationUi : public caf::PdmObject +{ + CAF_PDM_HEADER_INIT; + +public: + struct Location + { + QString name; + QString folder; + }; + + RicWorkflowLocationUi(); + + void setName( const QString& name ); + QString name() const; + QString folder() const; + + static std::optional askForLocation( const QString& title, const QString& defaultName ); + static QString defaultFolder( const QString& name ); + +protected: + void fieldChangedByUi( const caf::PdmFieldHandle* changedField, const QVariant& oldValue, const QVariant& newValue ) override; + +private: + caf::PdmField m_name; + caf::PdmField m_folder; + bool m_folderEditedByUser = false; +}; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/CMakeLists_files.cmake b/ApplicationLibCode/ProjectDataModel/Workflow/CMakeLists_files.cmake index d711999983..81f0528a46 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/CMakeLists_files.cmake +++ b/ApplicationLibCode/ProjectDataModel/Workflow/CMakeLists_files.cmake @@ -1,7 +1,15 @@ set(SOURCE_GROUP_SOURCE_FILES ${CMAKE_CURRENT_LIST_DIR}/RimWorkflow.cpp ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowCollection.cpp + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowValidationTools.cpp + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowInstalledCollection.cpp ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowDescribeTools.cpp + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowSchemaTools.cpp + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowDefinition.cpp + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowDefinitionTools.cpp + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowPortCompatibility.cpp + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowHelperProcess.cpp + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowTaskCatalog.cpp ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowJob.cpp ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowTaskInput.cpp ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowFieldBinding.cpp diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.cpp index a2b819ee78..f16789b6c7 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.cpp @@ -15,30 +15,37 @@ // for more details. // ///////////////////////////////////////////////////////////////////////////////// - #include "RimWorkflow.h" +#include "RimWorkflowDefinitionTools.h" #include "RimWorkflowDescribeTools.h" +#include "RimWorkflowHelperProcess.h" #include "RimWorkflowJob.h" -#include "RimWorkflowTaskInput.h" +#include "RimWorkflowValidationTools.h" #include "RiaLogging.h" #include "RiaPreferences.h" -#include "RiaQStringFormatter.h" + +#include "RiuMainWindow.h" #include "cafCmdFeatureMenuBuilder.h" +#include "cafPdmUiOrdering.h" #include +#include #include #include #include -#include -#include -#include #include +#include CAF_PDM_SOURCE_INIT( RimWorkflow, "Workflow" ); +namespace +{ +constexpr size_t maxUndoSteps = 100; +} + //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- @@ -55,12 +62,26 @@ RimWorkflow::RimWorkflow() CAF_PDM_InitFieldNoDefault( &m_workflowDirectory, "WorkflowDirectory", "Directory" ); m_workflowDirectory.uiCapability()->setUiReadOnly( true ); + CAF_PDM_InitFieldNoDefault( &m_registeredId, "RegisteredId", "Registered Id" ); + m_registeredId.uiCapability()->setUiReadOnly( true ); + + CAF_PDM_InitField( &m_editable, "Editable", false, "Editable" ); + m_editable.uiCapability()->setUiReadOnly( true ); + + CAF_PDM_InitFieldNoDefault( &m_readOnlyReason, "ReadOnlyReason", "Read-only Because" ); + m_readOnlyReason.uiCapability()->setUiReadOnly( true ); + CAF_PDM_InitFieldNoDefault( &m_loadError, "LoadError", "Load Error" ); m_loadError.uiCapability()->setUiReadOnly( true ); CAF_PDM_InitFieldNoDefault( &m_jobs, "Jobs", "" ); } +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RimWorkflow::~RimWorkflow() = default; + //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- @@ -75,9 +96,9 @@ QString RimWorkflow::name() const void RimWorkflow::setWorkflowDirectory( const QString& directory ) { m_workflowDirectory = directory; - QString name = QFileInfo( QDir( directory ).absolutePath() ).fileName(); - m_name = name; - setUiName( name ); + m_sourceDirectory = directory; + m_name = QFileInfo( QDir( directory ).absolutePath() ).fileName(); + updateUiName(); } //-------------------------------------------------------------------------------------------------- @@ -111,9 +132,9 @@ std::vector RimWorkflow::jobs() const { std::vector result; result.reserve( m_jobs.size() ); - for ( RimWorkflowJob* j : m_jobs.childrenByType() ) + for ( RimWorkflowJob* job : m_jobs.childrenByType() ) { - if ( j ) result.push_back( j ); + if ( job ) result.push_back( job ); } return result; } @@ -123,7 +144,19 @@ std::vector RimWorkflow::jobs() const //-------------------------------------------------------------------------------------------------- void RimWorkflow::addJob( RimWorkflowJob* job ) { - if ( job ) m_jobs.push_back( job ); + if ( !job ) return; + m_jobs.push_back( job ); + job->syncTaskInputs( m_graph.value( "tasks" ).toArray() ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RimWorkflow::syncJobs( const QMap& renames ) +{ + const QJsonArray tasks = m_graph.value( "tasks" ).toArray(); + for ( RimWorkflowJob* job : jobs() ) + job->syncTaskInputs( tasks, renames ); } //-------------------------------------------------------------------------------------------------- @@ -132,115 +165,653 @@ void RimWorkflow::addJob( RimWorkflowJob* job ) void RimWorkflow::appendMenuItems( caf::CmdFeatureMenuBuilder& menuBuilder ) const { menuBuilder << "RicNewWorkflowJobFeature"; + menuBuilder << "Separator"; + menuBuilder << "RicSaveWorkflowFeature"; + menuBuilder << "RicSaveWorkflowAsFeature"; + menuBuilder << "RicDuplicateWorkflowAsEditableFeature"; } //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- +void RimWorkflow::defineUiOrdering( QString uiConfigName, caf::PdmUiOrdering& uiOrdering ) +{ + m_name.uiCapability()->setUiReadOnly( !isEditable() || isLocked() ); + + uiOrdering.add( &m_name ); + uiOrdering.add( &m_description ); + if ( m_source == Source::Registered ) + uiOrdering.add( &m_registeredId ); + else if ( !workflowDirectory().isEmpty() ) + uiOrdering.add( &m_workflowDirectory ); + uiOrdering.add( &m_editable ); + if ( !m_readOnlyReason().isEmpty() ) uiOrdering.add( &m_readOnlyReason ); + if ( !m_loadError().isEmpty() ) uiOrdering.add( &m_loadError ); + uiOrdering.skipRemainingFields( true ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RimWorkflow::fieldChangedByUi( const caf::PdmFieldHandle* changedField, const QVariant& oldValue, const QVariant& newValue ) +{ + if ( changedField != &m_name ) return; + + auto result = applyEdit( RimWorkflowDefinitionTools::renameWorkflow( m_definition, m_name() ) ); + if ( !result ) + { + RiaLogging::warning( result.error().toStdString() ); + m_name = m_definition.name; + updateUiName(); + } +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RimWorkflow::setLoadError( const QString& message, QString* errorMessage ) +{ + m_loadError = message; + RiaLogging::warning( QString( "Workflow '%1': %2" ).arg( m_name(), message ).toStdString() ); + if ( errorMessage ) *errorMessage = message; + return false; +} + +//-------------------------------------------------------------------------------------------------- +/// Load a YAML folder through `taskmaestro_helper load --describe` +//-------------------------------------------------------------------------------------------------- bool RimWorkflow::loadFromDirectory( QString* errorMessage ) { - m_jobs.deleteChildren(); m_graph = {}; m_loadError = ""; - const QString dir = workflowDirectory(); - if ( dir.isEmpty() ) return false; + const QString directory = workflowDirectory(); + if ( directory.isEmpty() ) return false; + if ( !QDir( directory ).exists( "workflow.yaml" ) ) return setLoadError( "Missing workflow.yaml", errorMessage ); - QString python = RimWorkflow::findPythonExecutable(); - if ( python.isEmpty() ) + auto output = RimWorkflowHelperProcess::runSync( { "load", QDir( directory ).absolutePath(), "--describe" } ); + if ( !output ) return setLoadError( output.error(), errorMessage ); + + auto definition = RimWorkflowDefinitionTools::fromJson( output.value() ); + if ( !definition ) return setLoadError( definition.error(), errorMessage ); + + m_registeredId = ""; + setDefinition( definition.value(), Source::YamlDirectory ); + recordModificationTime(); + + const auto validation = + RimWorkflowValidationTools::issuesFromDescribe( m_definition.describe, m_definition.describeError, m_definition.nodeNames() ); + setValidationResult( m_revision, validation.issues, validation.describeSucceeded ); + if ( !validation.describeSucceeded ) { - m_loadError = "No usable Python interpreter found"; - RiaLogging::warning( QString( "Workflow '%1': %2" ).arg( dir, m_loadError() ).toStdString() ); - if ( errorMessage ) *errorMessage = m_loadError; - return false; + m_loadError = RimWorkflowHelperProcess::errorMessage( m_definition.describeError ); + RiaLogging::warning( QString( "Workflow '%1' is not valid: %2" ).arg( m_name(), m_loadError() ).toStdString() ); } - const QDir workflowDir( dir ); - if ( !workflowDir.exists( "workflow.yaml" ) ) + RiaLogging::info( + QString( "Loaded workflow '%1' (%2 tasks) from %3" ).arg( m_name() ).arg( m_definition.nodes.size() ).arg( directory ).toStdString() ); + return true; +} + +//-------------------------------------------------------------------------------------------------- +/// A workflow registered by an installed package, as listed by `taskmaestro_helper catalog`. +/// Registered workflows are read-only; Duplicate as Editable makes an editable copy. +//-------------------------------------------------------------------------------------------------- +bool RimWorkflow::loadFromRegistered( const QJsonObject& catalogEntry, QString* errorMessage ) +{ + m_graph = {}; + m_loadError = ""; + m_registeredId = catalogEntry.value( "id" ).toString(); + m_name = catalogEntry.value( "name" ).toString( m_registeredId() ); + m_sourceDirectory.clear(); + + RimWorkflowDefinition definition; + if ( catalogEntry.value( "definition" ).isObject() ) { - m_loadError = "Missing workflow.yaml"; - RiaLogging::warning( std::format( "Workflow '{}': {}", dir, m_loadError() ) ); - if ( errorMessage ) *errorMessage = m_loadError; - return false; + auto parsed = RimWorkflowDefinitionTools::fromJson( catalogEntry.value( "definition" ).toObject() ); + if ( !parsed ) return setLoadError( parsed.error(), errorMessage ); + definition = parsed.value(); + } + else + { + definition.name = m_name(); + definition.editable = false; } - QStringList args{ "-m", "taskmaestro", "workflow", "describe", workflowDir.absoluteFilePath( "workflow.yaml" ), "--json" }; - if ( workflowDir.exists( "input.yaml" ) ) args << "--input" << workflowDir.absoluteFilePath( "input.yaml" ); - - QProcess proc; - QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); - proc.setProcessEnvironment( env ); - proc.start( python, args ); - if ( !proc.waitForStarted( 10000 ) ) + // `definition.editable` tells whether an editable copy is possible; the installed workflow + // itself is always read-only (see isEditable()) + for ( const QJsonValue& reason : catalogEntry.value( "readonly_reasons" ).toArray() ) { - m_loadError = QString( "Could not launch '%1'" ).arg( python ); - RiaLogging::warning( QString( "Workflow '%1': %2" ).arg( dir, m_loadError() ).toStdString() ); - if ( errorMessage ) *errorMessage = m_loadError; - return false; - } - if ( !proc.waitForFinished( 30000 ) ) - { - proc.kill(); - m_loadError = "taskmaestro workflow describe timed out"; - RiaLogging::warning( QString( "Workflow '%1': %2" ).arg( dir, m_loadError() ).toStdString() ); - if ( errorMessage ) *errorMessage = m_loadError; - return false; + if ( !definition.readOnlyReasons.contains( reason.toString() ) ) definition.readOnlyReasons.append( reason.toString() ); } + if ( !definition.readOnlyReasons.isEmpty() ) definition.editable = false; + if ( definition.editable ) definition.readOnlyReasons.append( "Installed workflows are read-only; duplicate to edit" ); + if ( definition.describe.isEmpty() ) definition.describe = catalogEntry.value( "describe" ).toObject(); - const QByteArray stdoutBytes = proc.readAllStandardOutput(); - QJsonParseError parseErr{}; - const QJsonDocument doc = QJsonDocument::fromJson( stdoutBytes, &parseErr ); + setDefinition( definition, Source::Registered ); + if ( m_graph.value( "tasks" ).toArray().isEmpty() ) return setLoadError( "The workflow could not be described", errorMessage ); + return true; +} - if ( proc.exitStatus() != QProcess::NormalExit || proc.exitCode() != 0 ) - { - // In JSON mode, configuration errors are reported on stdout; other errors on stderr - m_loadError = RimWorkflowDescribeTools::errorFromDescribe( doc.object() ); - if ( m_loadError().isEmpty() ) m_loadError = QString::fromUtf8( proc.readAllStandardError() ).trimmed(); - if ( m_loadError().isEmpty() ) m_loadError = "taskmaestro workflow describe failed"; - RiaLogging::warning( std::format( "Workflow '{}' describe failed: {}", dir, m_loadError() ) ); - if ( errorMessage ) *errorMessage = m_loadError; - return false; - } +//-------------------------------------------------------------------------------------------------- +/// A new workflow, or an editable copy. `sourceDirectory` holds Python modules used by the tasks, +/// and `targetDirectory` is the folder the first Save writes to. +//-------------------------------------------------------------------------------------------------- +void RimWorkflow::setUnsavedDefinition( const RimWorkflowDefinition& definition, const QString& sourceDirectory, const QString& targetDirectory ) +{ + m_workflowDirectory = targetDirectory; + m_registeredId = ""; + m_loadError = ""; + m_sourceDirectory = sourceDirectory; - if ( parseErr.error != QJsonParseError::NoError || !doc.isObject() ) - { - m_loadError = QString( "Invalid JSON from taskmaestro workflow describe: %1" ).arg( parseErr.errorString() ); - if ( errorMessage ) *errorMessage = m_loadError; - return false; - } + RimWorkflowDefinition editable = definition; + if ( definition.editable ) editable.readOnlyReasons.clear(); + editable.readOnlyReasons.append( RimWorkflowDefinitionTools::structuralReadOnlyReasons( definition ) ); + editable.readOnlyReasons.removeDuplicates(); + editable.editable = editable.readOnlyReasons.isEmpty(); + setDefinition( editable, Source::Unsaved ); +} - if ( !doc.object().value( "tasks" ).isArray() ) - { - m_loadError = "Invalid workflow graph schema"; - if ( errorMessage ) *errorMessage = m_loadError; - return false; - } - const QJsonObject root = RimWorkflowDescribeTools::graphFromDescribe( doc.object() ); - 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() ); +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RimWorkflow::setDefinition( const RimWorkflowDefinition& definition, Source source ) +{ + m_source = source; + m_definition = definition; + m_savedDefinition = definition; + m_undoStack.clear(); + m_redoStack.clear(); + m_validationIssues.clear(); + m_validationRevision = -1; + ++m_revision; - std::vector taskInputs; - for ( const QJsonValue& tv : root.value( "tasks" ).toArray() ) + if ( !definition.name.isEmpty() ) m_name = definition.name; + m_description = definition.headerComment; + m_editable = isEditable(); + m_readOnlyReason = definition.readOnlyReasons.join( "\n" ); + + rebuildGraph(); + ensureDefaultJob(); + syncJobs(); + updateUiName(); + notifyDefinitionChanged(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RimWorkflow::Source RimWorkflow::source() const +{ + return m_source; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QString RimWorkflow::registeredId() const +{ + return m_registeredId(); +} + +//-------------------------------------------------------------------------------------------------- +/// The folder with the Python modules of the workflow (the YAML folder or the folder it was copied from) +//-------------------------------------------------------------------------------------------------- +QString RimWorkflow::sourceDirectory() const +{ + return m_sourceDirectory; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +const RimWorkflowDefinition& RimWorkflow::definition() const +{ + return m_definition; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RimWorkflow::isEditable() const +{ + return m_definition.editable && m_source != Source::Registered; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QString RimWorkflow::readOnlyReason() const +{ + return m_readOnlyReason(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RimWorkflow::isDirty() const +{ + return m_source == Source::Unsaved || m_definition != m_savedDefinition; +} + +//-------------------------------------------------------------------------------------------------- +/// The workflow cannot be edited while one of its jobs is running +//-------------------------------------------------------------------------------------------------- +bool RimWorkflow::isLocked() const +{ + for ( RimWorkflowJob* job : jobs() ) { - const QJsonObject taskObj = tv.toObject(); - 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 ); + if ( job->isRunning() ) return true; } + return false; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +int RimWorkflow::revision() const +{ + return m_revision; +} + +//-------------------------------------------------------------------------------------------------- +/// Apply the result of an edit operation from RimWorkflowDefinitionTools. `renames` maps old task +/// names to new names, so that input values follow renamed tasks. +//-------------------------------------------------------------------------------------------------- +std::expected RimWorkflow::applyEdit( const std::expected& result, + const QMap& renames ) +{ + if ( !result ) return std::unexpected( result.error() ); + if ( !isEditable() ) + return std::unexpected( m_readOnlyReason().isEmpty() ? QString( "The workflow is read-only" ) : m_readOnlyReason() ); + if ( isLocked() ) return std::unexpected( "The workflow cannot be edited while a job is running" ); + if ( result.value() == m_definition ) return {}; + + m_undoStack.push_back( { m_definition, renames } ); + if ( m_undoStack.size() > maxUndoSteps ) m_undoStack.erase( m_undoStack.begin() ); + m_redoStack.clear(); + + m_definition = result.value(); + definitionChanged( renames ); + return {}; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RimWorkflow::canUndo() const +{ + return isEditable() && !isLocked() && !m_undoStack.empty(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RimWorkflow::canRedo() const +{ + return isEditable() && !isLocked() && !m_redoStack.empty(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RimWorkflow::undo() +{ + if ( !canUndo() ) return; + + UndoEntry entry = m_undoStack.back(); + m_undoStack.pop_back(); + m_redoStack.push_back( { m_definition, entry.renames } ); + m_definition = entry.definition; + definitionChanged( invertedRenames( entry.renames ) ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RimWorkflow::redo() +{ + if ( !canRedo() ) return; + + UndoEntry entry = m_redoStack.back(); + m_redoStack.pop_back(); + m_undoStack.push_back( { m_definition, entry.renames } ); + m_definition = entry.definition; + definitionChanged( entry.renames ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RimWorkflow::definitionChanged( const QMap& renames ) +{ + ++m_revision; + m_name = m_definition.name; + m_description = m_definition.headerComment; + + // Keep the issues that have not been checked again until the next validation + rebuildGraph(); + syncJobs( renames ); + updateUiName(); + notifyDefinitionChanged(); +} + +//-------------------------------------------------------------------------------------------------- +/// Let the workflow editor show the new state +//-------------------------------------------------------------------------------------------------- +void RimWorkflow::notifyDefinitionChanged() +{ + if ( auto* window = RiuMainWindow::instance() ) window->workflowDefinitionChanged( this ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QMap RimWorkflow::invertedRenames( const QMap& renames ) +{ + QMap inverted; + for ( auto it = renames.begin(); it != renames.end(); ++it ) + inverted.insert( it.value(), it.key() ); + return inverted; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RimWorkflow::rebuildGraph() +{ + if ( isEditable() ) + { + std::vector describeIssues; + if ( isValidationCurrent() ) describeIssues = m_validationIssues; + m_graph = RimWorkflowDefinitionTools::graphFromDefinition( m_definition, true, describeIssues ); + } + else if ( !m_definition.describe.isEmpty() ) + { + m_graph = RimWorkflowDescribeTools::graphFromDescribe( m_definition.describe ); + m_graph["editable"] = false; + } + else + { + m_graph = RimWorkflowDefinitionTools::graphFromDefinition( m_definition, false ); + } +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RimWorkflow::updateUiName() +{ + setUiName( isDirty() && isEditable() ? m_name() + " *" : m_name() ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RimWorkflow::ensureDefaultJob() +{ + if ( !m_jobs.empty() ) return; auto* job = new RimWorkflowJob; job->setJobName( "Default" ); - job->setTaskInputs( taskInputs ); m_jobs.push_back( job ); +} - RiaLogging::info( - QString( "Loaded workflow '%1' (%2 tasks with config) from %3" ).arg( m_name() ).arg( taskInputs.size() ).arg( dir ).toStdString() ); +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RimWorkflow::setValidationResult( int revision, const std::vector& issues, bool describeSucceeded ) +{ + if ( revision != m_revision ) return; - return true; + m_validationIssues = issues; + m_validationRevision = revision; + m_describeSucceeded = describeSucceeded; + rebuildGraph(); + notifyDefinitionChanged(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +std::vector RimWorkflow::validationIssues() const +{ + return m_validationIssues; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RimWorkflow::isValidationCurrent() const +{ + return m_validationRevision == m_revision; +} + +//-------------------------------------------------------------------------------------------------- +/// True when the current revision has errors, from the static checks or from the last describe +//-------------------------------------------------------------------------------------------------- +bool RimWorkflow::lastValidationFailed() const +{ + if ( isValidationCurrent() && !m_describeSucceeded ) return true; + if ( !isEditable() ) return false; + for ( const auto& issue : RimWorkflowDefinitionTools::validate( m_definition ) ) + { + if ( issue.severity == RimWorkflowIssue::Severity::Error ) return true; + } + return false; +} + +//-------------------------------------------------------------------------------------------------- +/// input.yaml content written on save: the literal values of the first job, falling back to the +/// values loaded from the file for tasks that have no inputs in the job +//-------------------------------------------------------------------------------------------------- +QJsonObject RimWorkflow::inputValuesForSave() const +{ + QJsonObject inputs = m_definition.inputs; + const auto allJobs = jobs(); + if ( allJobs.empty() ) return inputs; + + const QJsonObject literal = allJobs.front()->literalInputValues(); + for ( const auto& node : m_definition.nodes ) + { + if ( literal.contains( node.name ) ) inputs[node.name] = literal.value( node.name ); + } + return inputs; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +std::expected RimWorkflow::saveToDirectory( const QString& directory, bool backup ) +{ + QStringList args{ "save", "--out-dir", directory, "--describe" }; + if ( backup ) args << "--backup"; + + auto output = + RimWorkflowHelperProcess::runSync( args, definitionJsonForSave(), RimWorkflowHelperProcess::defaultTimeoutMs, m_sourceDirectory ); + if ( !output ) return output; + + m_definition.inputs = inputValuesForSave(); + m_definition.describe = output->value( "describe" ).toObject(); + m_definition.describeError = output->value( "describe_error" ).toObject(); + m_definition.source = QJsonObject{ { "workflow_yaml", output->value( "workflow_yaml" ) }, + { "input_yaml", output->value( "input_yaml" ) }, + { "registered_id", QJsonValue::Null } }; + m_savedDefinition = m_definition; + m_source = Source::YamlDirectory; + m_loadedModificationTime = QFileInfo( QDir( directory ).absoluteFilePath( "workflow.yaml" ) ).lastModified(); + + const auto validation = RimWorkflowValidationTools::issuesFromHelperResult( output.value(), m_definition.nodeNames() ); + setValidationResult( m_revision, validation.issues, validation.describeSucceeded ); + updateUiName(); + return output; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RimWorkflow::recordModificationTime() +{ + m_loadedModificationTime = QFileInfo( QDir( workflowDirectory() ).absoluteFilePath( "workflow.yaml" ) ).lastModified(); +} + +//-------------------------------------------------------------------------------------------------- +/// Reasons to think twice before overwriting the workflow folder: workflow.yaml was changed by +/// someone else since it was loaded, or the folder is a link into a git checkout +//-------------------------------------------------------------------------------------------------- +QStringList RimWorkflow::saveInPlaceWarnings() const +{ + QStringList warnings; + if ( m_source != Source::YamlDirectory || workflowDirectory().isEmpty() ) return warnings; + + const QFileInfo yamlFile( QDir( workflowDirectory() ).absoluteFilePath( "workflow.yaml" ) ); + if ( m_loadedModificationTime.isValid() && yamlFile.exists() && yamlFile.lastModified() != m_loadedModificationTime ) + { + warnings << "workflow.yaml has been changed on disk since it was loaded. Saving overwrites those changes."; + } + + const QFileInfo directoryInfo( workflowDirectory() ); + if ( directoryInfo.isSymLink() || yamlFile.isSymLink() ) + { + QDir dir( yamlFile.canonicalPath() ); + do + { + if ( dir.exists( ".git" ) ) + { + warnings + << QString( "The workflow folder links into the git checkout '%1'. Saving changes files there." ).arg( dir.absolutePath() ); + break; + } + } while ( dir.cdUp() ); + } + return warnings; +} + +//-------------------------------------------------------------------------------------------------- +/// Write workflow.yaml and input.yaml to the workflow folder. A backup is made the first time. +//-------------------------------------------------------------------------------------------------- +std::expected RimWorkflow::save() +{ + if ( !isEditable() ) return std::unexpected( "The workflow is read-only" ); + if ( workflowDirectory().isEmpty() ) return std::unexpected( "The workflow has no folder; use Save As" ); + if ( m_source == Source::Unsaved ) return saveAs( workflowDirectory() ); + + auto output = saveToDirectory( workflowDirectory(), true ); + if ( !output ) return std::unexpected( output.error() ); + + RiaLogging::info( QString( "Saved workflow '%1' to %2" ).arg( m_name(), workflowDirectory() ).toStdString() ); + return {}; +} + +//-------------------------------------------------------------------------------------------------- +/// Save to a new folder. Python modules next to the original workflow.yaml are copied along. +//-------------------------------------------------------------------------------------------------- +std::expected RimWorkflow::saveAs( const QString& directory ) +{ + if ( !isEditable() ) return std::unexpected( "The workflow is read-only" ); + if ( directory.isEmpty() ) return std::unexpected( "No folder given" ); + + QDir target( directory ); + if ( !target.exists() && !QDir().mkpath( directory ) ) + return std::unexpected( QString( "Could not create the folder '%1'" ).arg( directory ) ); + + if ( !m_sourceDirectory.isEmpty() && QDir( m_sourceDirectory ).absolutePath() != target.absolutePath() ) + { + const QDir source( m_sourceDirectory ); + for ( const QString& fileName : source.entryList( { "*.py" }, QDir::Files ) ) + { + if ( !target.exists( fileName ) ) QFile::copy( source.absoluteFilePath( fileName ), target.absoluteFilePath( fileName ) ); + } + } + + auto output = saveToDirectory( target.absolutePath(), false ); + if ( !output ) return std::unexpected( output.error() ); + + m_workflowDirectory = target.absolutePath(); + m_sourceDirectory = target.absolutePath(); + m_registeredId = ""; + updateUiName(); + notifyDefinitionChanged(); + + RiaLogging::info( QString( "Saved workflow '%1' to %2" ).arg( m_name(), target.absolutePath() ).toStdString() ); + return {}; +} + +//-------------------------------------------------------------------------------------------------- +/// Write the current revision as workflow.yaml to a private temporary folder, used to run and +/// validate unsaved edits +//-------------------------------------------------------------------------------------------------- +std::expected RimWorkflow::writeEditDirectorySync() +{ + auto directory = editDirectory(); + if ( !directory ) return directory; + if ( m_editDirectoryRevision == m_revision ) return directory; + + auto output = RimWorkflowHelperProcess::runSync( { "save", "--out-dir", directory.value(), "--workflow-only" }, + definitionJsonForSave(), + RimWorkflowHelperProcess::defaultTimeoutMs, + m_sourceDirectory ); + if ( !output ) return std::unexpected( output.error() ); + + m_editDirectoryRevision = m_revision; + return directory; +} + +//-------------------------------------------------------------------------------------------------- +/// The temporary folder that holds the YAML of unsaved edits, for validation and for running +//-------------------------------------------------------------------------------------------------- +std::expected RimWorkflow::editDirectory() +{ + if ( !m_editDirectory ) + { + m_editDirectory = std::make_unique( QDir::tempPath() + "/resinsight_workflow_edit_XXXXXX" ); + if ( !m_editDirectory->isValid() ) + { + m_editDirectory.reset(); + return std::unexpected( "Could not create a temporary folder for the workflow" ); + } + } + return m_editDirectory->path(); +} + +//-------------------------------------------------------------------------------------------------- +/// Called when the edit folder has been written for `revision` by someone else (live validation) +//-------------------------------------------------------------------------------------------------- +void RimWorkflow::setEditDirectoryRevision( int revision ) +{ + if ( revision == m_revision ) m_editDirectoryRevision = revision; +} + +//-------------------------------------------------------------------------------------------------- +/// The definition as sent to `taskmaestro_helper save` +//-------------------------------------------------------------------------------------------------- +QByteArray RimWorkflow::definitionJsonForSave() const +{ + RimWorkflowDefinition toSave = m_definition; + toSave.inputs = inputValuesForSave(); + return QJsonDocument( RimWorkflowDefinitionTools::toJson( toSave ) ).toJson( QJsonDocument::Compact ); +} + +//-------------------------------------------------------------------------------------------------- +/// The arguments of `taskmaestro_helper run` for the current state of the workflow +//-------------------------------------------------------------------------------------------------- +std::expected RimWorkflow::prepareRunSource() +{ + if ( m_source == Source::Registered ) + { + if ( m_registeredId().isEmpty() ) return std::unexpected( "The workflow has no registered id" ); + return RunSource{ .baseArgs = { "run", "--registered", m_registeredId() } }; + } + + if ( m_source == Source::YamlDirectory && !isDirty() ) + { + if ( workflowDirectory().isEmpty() ) return std::unexpected( "The workflow has no folder" ); + return RunSource{ .baseArgs = { "run", workflowDirectory() }, .extraPythonPath = workflowDirectory() }; + } + + auto editDirectory = writeEditDirectorySync(); + if ( !editDirectory ) return std::unexpected( editDirectory.error() ); + return RunSource{ .baseArgs = { "run", editDirectory.value() }, .extraPythonPath = m_sourceDirectory }; } //-------------------------------------------------------------------------------------------------- @@ -256,15 +827,15 @@ QString RimWorkflow::findPythonExecutable() } candidates << "python3" << "python"; - for ( const QString& cand : candidates ) + for ( const QString& candidate : candidates ) { - if ( cand.contains( '/' ) || cand.contains( '\\' ) ) + if ( candidate.contains( '/' ) || candidate.contains( '\\' ) ) { - if ( QFileInfo( cand ).isExecutable() ) return cand; + if ( QFileInfo( candidate ).isExecutable() ) return candidate; } - else if ( !QStandardPaths::findExecutable( cand ).isEmpty() ) + else if ( !QStandardPaths::findExecutable( candidate ).isEmpty() ) { - return cand; + return candidate; } } return {}; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.h index 17b8465cad..8d20a05087 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.h @@ -15,24 +15,51 @@ // for more details. // ///////////////////////////////////////////////////////////////////////////////// - #pragma once +#include "RimWorkflowDefinition.h" + #include "cafFilePath.h" #include "cafPdmChildArrayField.h" #include "cafPdmField.h" #include "cafPdmObject.h" +#include #include +#include + +#include +#include +#include class RimWorkflowJob; +class QTemporaryDir; +//================================================================================================== +/// A taskmaestro workflow: a YAML folder, a workflow registered by an installed package, or a new +/// workflow that has not been saved yet. Editable workflows keep their definition in memory with +/// undo/redo until saved. +//================================================================================================== class RimWorkflow : public caf::PdmObject { CAF_PDM_HEADER_INIT; public: + enum class Source + { + YamlDirectory, + Registered, + Unsaved + }; + + struct RunSource + { + QStringList baseArgs{}; + QString extraPythonPath{}; + }; + RimWorkflow(); + ~RimWorkflow() override; QString name() const; void setWorkflowDirectory( const QString& directory ); @@ -41,20 +68,99 @@ public: QString loadError() const; bool loadFromDirectory( QString* errorMessage = nullptr ); + bool loadFromRegistered( const QJsonObject& catalogEntry, QString* errorMessage = nullptr ); + void setUnsavedDefinition( const RimWorkflowDefinition& definition, + const QString& sourceDirectory = {}, + const QString& targetDirectory = {} ); + + Source source() const; + QString registeredId() const; + QString sourceDirectory() const; + const RimWorkflowDefinition& definition() const; + bool isEditable() const; + QString readOnlyReason() const; + bool isDirty() const; + bool isLocked() const; + int revision() const; + + // Editing + std::expected applyEdit( const std::expected& result, + const QMap& renames = {} ); + bool canUndo() const; + bool canRedo() const; + void undo(); + void redo(); + + // Saving and running + std::expected save(); + QStringList saveInPlaceWarnings() const; + std::expected saveAs( const QString& directory ); + std::expected writeEditDirectorySync(); + std::expected editDirectory(); + void setEditDirectoryRevision( int revision ); + QByteArray definitionJsonForSave() const; + std::expected prepareRunSource(); + QJsonObject inputValuesForSave() const; + + // Validation of the current revision (static checks plus `taskmaestro workflow describe`) + void setValidationResult( int revision, const std::vector& issues, bool describeSucceeded ); + std::vector validationIssues() const; + bool isValidationCurrent() const; + bool lastValidationFailed() const; std::vector jobs() const; void addJob( RimWorkflowJob* job ); + void syncJobs( const QMap& renames = {} ); static QString findPythonExecutable(); protected: void appendMenuItems( caf::CmdFeatureMenuBuilder& menuBuilder ) const override; + void defineUiOrdering( QString uiConfigName, caf::PdmUiOrdering& uiOrdering ) override; + void fieldChangedByUi( const caf::PdmFieldHandle* changedField, const QVariant& oldValue, const QVariant& newValue ) override; + +private: + struct UndoEntry + { + RimWorkflowDefinition definition; + QMap renames; + }; + + bool setLoadError( const QString& message, QString* errorMessage ); + void setDefinition( const RimWorkflowDefinition& definition, Source source ); + void definitionChanged( const QMap& renames ); + void notifyDefinitionChanged(); + void rebuildGraph(); + void updateUiName(); + void ensureDefaultJob(); + + std::expected saveToDirectory( const QString& directory, bool backup ); + + void recordModificationTime(); + static QMap invertedRenames( const QMap& renames ); private: caf::PdmField m_name; caf::PdmField m_description; caf::PdmField m_workflowDirectory; + caf::PdmField m_registeredId; + caf::PdmField m_editable; + caf::PdmField m_readOnlyReason; caf::PdmField m_loadError; caf::PdmChildArrayField m_jobs; QJsonObject m_graph; + + Source m_source = Source::YamlDirectory; + QString m_sourceDirectory; + RimWorkflowDefinition m_definition; + RimWorkflowDefinition m_savedDefinition; + int m_revision = 0; + std::vector m_undoStack; + std::vector m_redoStack; + std::vector m_validationIssues; + int m_validationRevision = -1; + bool m_describeSucceeded = true; + QDateTime m_loadedModificationTime; + std::unique_ptr m_editDirectory; + int m_editDirectoryRevision = -1; }; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowArrayBinding.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowArrayBinding.cpp index ce8f108571..ed3aa6f6b3 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowArrayBinding.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowArrayBinding.cpp @@ -47,7 +47,9 @@ void RimWorkflowArrayBinding::applySchema( const QJsonObject& fieldSchema ) //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -QString RimWorkflowArrayBinding::toYamlValue() const +QJsonValue RimWorkflowArrayBinding::toJsonValue() const { - return hasValue() ? m_value() : "null"; + if ( !hasValue() ) return QJsonValue::Null; + const QJsonDocument document = QJsonDocument::fromJson( m_value().toUtf8() ); + return document.isArray() ? QJsonValue( document.array() ) : QJsonValue( m_value() ); } diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowArrayBinding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowArrayBinding.h index 44b025d3f6..99d6632a88 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowArrayBinding.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowArrayBinding.h @@ -27,8 +27,8 @@ class RimWorkflowArrayBinding : public RimWorkflowFieldBinding public: RimWorkflowArrayBinding(); - void applySchema( const QJsonObject& fieldSchema ) override; - QString toYamlValue() const override; + void applySchema( const QJsonObject& fieldSchema ) override; + QJsonValue toJsonValue() const override; caf::PdmFieldHandle* valueField() override { return &m_value; } diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowBoolBinding.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowBoolBinding.cpp index a8e6bebbbe..7d21b8baa6 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowBoolBinding.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowBoolBinding.cpp @@ -45,8 +45,7 @@ void RimWorkflowBoolBinding::applySchema( const QJsonObject& fieldSchema ) //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -QString RimWorkflowBoolBinding::toYamlValue() const +QJsonValue RimWorkflowBoolBinding::toJsonValue() const { - if ( !hasValue() ) return "null"; - return m_value() ? "true" : "false"; + return hasValue() ? QJsonValue( m_value() ) : QJsonValue( QJsonValue::Null ); } diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowBoolBinding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowBoolBinding.h index 7dceffe2da..f2370faacc 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowBoolBinding.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowBoolBinding.h @@ -27,8 +27,8 @@ class RimWorkflowBoolBinding : public RimWorkflowFieldBinding public: RimWorkflowBoolBinding(); - void applySchema( const QJsonObject& fieldSchema ) override; - QString toYamlValue() const override; + void applySchema( const QJsonObject& fieldSchema ) override; + QJsonValue toJsonValue() const override; caf::PdmFieldHandle* valueField() override { return &m_value; } diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCaseBinding.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCaseBinding.cpp index f4436eaa53..cc16e18c98 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCaseBinding.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCaseBinding.cpp @@ -19,8 +19,11 @@ #include "RimWorkflowCaseBinding.h" #include "RimEclipseCase.h" +#include "RimEclipseCaseTools.h" #include "RimTools.h" +#include + CAF_PDM_SOURCE_INIT( RimWorkflowCaseBinding, "WorkflowCaseBinding" ); //-------------------------------------------------------------------------------------------------- @@ -42,10 +45,10 @@ QString RimWorkflowCaseBinding::displayValue() const //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -QString RimWorkflowCaseBinding::toYamlValue() const +QJsonValue RimWorkflowCaseBinding::toJsonValue() const { - if ( m_case() == nullptr ) return "null"; - return QString( "{__resinsight_ref__: EclipseCase, case_id: %1}" ).arg( m_case()->caseId() ); + if ( m_case() == nullptr ) return QJsonValue::Null; + return QJsonObject{ { "__resinsight_ref__", "EclipseCase" }, { "case_id", m_case()->caseId() } }; } //-------------------------------------------------------------------------------------------------- @@ -57,3 +60,27 @@ QList RimWorkflowCaseBinding::calculateValueOptions( con if ( fieldNeedingOptions == &m_case ) RimTools::eclipseCaseOptionItems( &options ); return options; } + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RimWorkflowCaseBinding::isObjectReference() const +{ + return true; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RimWorkflowCaseBinding::applySchema( const QJsonObject& fieldSchema ) +{ + RimWorkflowFieldBinding::applySchema( fieldSchema ); + const QJsonObject reference = fieldSchema.value( "default" ).toObject(); + if ( reference.value( "__resinsight_ref__" ).toString() != "EclipseCase" || !reference.contains( "case_id" ) ) return; + + const int caseId = reference.value( "case_id" ).toInt( -1 ); + for ( RimEclipseCase* eclipseCase : RimEclipseCaseTools::eclipseCases() ) + { + if ( eclipseCase && eclipseCase->caseId() == caseId ) m_case = eclipseCase; + } +} diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCaseBinding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCaseBinding.h index 78ad861785..5dda19d25c 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCaseBinding.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCaseBinding.h @@ -31,8 +31,10 @@ class RimWorkflowCaseBinding : public RimWorkflowFieldBinding public: RimWorkflowCaseBinding(); - QString toYamlValue() const override; - QString displayValue() const override; + QJsonValue toJsonValue() const override; + bool isObjectReference() const override; + void applySchema( const QJsonObject& fieldSchema ) override; + QString displayValue() const override; caf::PdmFieldHandle* valueField() override { return &m_case; } diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCollection.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCollection.cpp index b9e15c348d..2130f961eb 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCollection.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCollection.cpp @@ -15,13 +15,20 @@ // for more details. // ///////////////////////////////////////////////////////////////////////////////// - #include "RimWorkflowCollection.h" +#include "RimWorkflowInstalledCollection.h" +#include "RimWorkflowTaskCatalog.h" + +#include "RiaLogging.h" #include "RiaPreferencesSystem.h" +#include "cafCmdFeatureMenuBuilder.h" +#include "cafPdmUiTreeOrdering.h" + #include #include +#include CAF_PDM_SOURCE_INIT( RimWorkflowCollection, "WorkflowCollection" ); @@ -33,9 +40,15 @@ RimWorkflowCollection::RimWorkflowCollection() CAF_PDM_InitObject( "Workflows", ":/Folder.png" ); CAF_PDM_InitFieldNoDefault( &m_items, "Workflows", "" ); + CAF_PDM_InitFieldNoDefault( &m_installed, "Installed", "" ); + m_installed = new RimWorkflowInstalledCollection; + rescanWorkflows(); } +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- RimWorkflowCollection::~RimWorkflowCollection() = default; //-------------------------------------------------------------------------------------------------- @@ -47,25 +60,102 @@ QString RimWorkflowCollection::discoveryDirectory() } //-------------------------------------------------------------------------------------------------- -/// +/// Reload the task catalog, the workflow folders and the installed workflows. Workflows that +/// have never been saved are kept. //-------------------------------------------------------------------------------------------------- void RimWorkflowCollection::rescanWorkflows() { - deleteAllItems(); + for ( RimWorkflow* workflow : items() ) + { + if ( workflow->source() != RimWorkflow::Source::Unsaved ) deleteItem( workflow ); + } + m_installed->deleteAllItems(); if ( !RiaPreferencesSystem::current()->isFeatureEnabled( "workflows" ) ) return; + RimWorkflowTaskCatalog::invalidate(); + const RimWorkflowTaskCatalog& catalog = RimWorkflowTaskCatalog::instance(); + if ( !catalog.isValid() ) RiaLogging::warning( QString( "Workflow task catalog: %1" ).arg( catalog.loadError() ).toStdString() ); + for ( const QString& pluginError : catalog.pluginErrors() ) + RiaLogging::warning( QString( "Workflow plugin: %1" ).arg( pluginError ).toStdString() ); + QDir dir( discoveryDirectory() ); - if ( !dir.exists() ) return; - - const QFileInfoList entries = dir.entryInfoList( QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name ); - for ( const QFileInfo& entry : entries ) + if ( dir.exists() ) { - if ( !QFileInfo( entry.absoluteFilePath() + "/workflow.yaml" ).isFile() ) continue; + const QFileInfoList entries = dir.entryInfoList( QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name ); + for ( const QFileInfo& entry : entries ) + { + if ( !QFileInfo( entry.absoluteFilePath() + "/workflow.yaml" ).isFile() ) continue; - auto* workflow = new RimWorkflow; - workflow->setWorkflowDirectory( entry.absoluteFilePath() ); - workflow->loadFromDirectory(); - addItem( workflow ); + auto* workflow = new RimWorkflow; + workflow->setWorkflowDirectory( entry.absoluteFilePath() ); + workflow->loadFromDirectory(); + addItem( workflow ); + } } + + for ( const QJsonValue& entry : catalog.registeredWorkflows() ) + { + auto* workflow = new RimWorkflow; + workflow->loadFromRegistered( entry.toObject() ); + m_installed->addItem( workflow ); + } + + uiCapability()->updateConnectedEditors(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RimWorkflowCollection::hasUnsavedChanges() const +{ + for ( RimWorkflow* workflow : items() ) + { + if ( workflow->isEditable() && workflow->isDirty() ) return true; + } + return false; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +std::vector RimWorkflowCollection::allWorkflows() const +{ + std::vector workflows = items(); + for ( RimWorkflow* workflow : m_installed->items() ) + workflows.push_back( workflow ); + return workflows; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RimWorkflow* RimWorkflowCollection::findWorkflowByDirectory( const QString& directory ) const +{ + const QString wanted = QDir( directory ).absolutePath(); + for ( RimWorkflow* workflow : items() ) + { + if ( !workflow->workflowDirectory().isEmpty() && QDir( workflow->workflowDirectory() ).absolutePath() == wanted ) return workflow; + } + return nullptr; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RimWorkflowCollection::appendMenuItems( caf::CmdFeatureMenuBuilder& menuBuilder ) const +{ + menuBuilder << "RicNewWorkflowFeature"; + menuBuilder << "RicRescanWorkflowsFeature"; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RimWorkflowCollection::defineUiTreeOrdering( caf::PdmUiTreeOrdering& uiTreeOrdering, QString uiConfigName ) +{ + for ( RimWorkflow* workflow : items() ) + uiTreeOrdering.add( workflow ); + if ( m_installed->count() > 0 ) uiTreeOrdering.add( m_installed() ); + uiTreeOrdering.skipRemainingChildren( true ); } diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCollection.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCollection.h index 49810d8a33..a64179e44b 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCollection.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowCollection.h @@ -15,15 +15,21 @@ // for more details. // ///////////////////////////////////////////////////////////////////////////////// - #pragma once #include "RimWorkflow.h" +#include "cafPdmChildField.h" #include "cafPdmObjectCollection.h" #include +class RimWorkflowInstalledCollection; + +//================================================================================================== +/// Workflow folders in the discovery folder, new unsaved workflows, and the workflows registered +/// by installed packages (in the "Installed" sub folder) +//================================================================================================== class RimWorkflowCollection : public caf::PdmObjectCollection { CAF_PDM_HEADER_INIT; @@ -33,6 +39,17 @@ public: ~RimWorkflowCollection() override; void rescanWorkflows(); + bool hasUnsavedChanges() const; + + std::vector allWorkflows() const; + RimWorkflow* findWorkflowByDirectory( const QString& directory ) const; static QString discoveryDirectory(); + +protected: + void appendMenuItems( caf::CmdFeatureMenuBuilder& menuBuilder ) const override; + void defineUiTreeOrdering( caf::PdmUiTreeOrdering& uiTreeOrdering, QString uiConfigName = "" ) override; + +private: + caf::PdmChildField m_installed; }; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDateBinding.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDateBinding.cpp index 4c931b0b40..a8c94bda8f 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDateBinding.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDateBinding.cpp @@ -46,7 +46,7 @@ void RimWorkflowDateBinding::applySchema( const QJsonObject& fieldSchema ) //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -QString RimWorkflowDateBinding::toYamlValue() const +QJsonValue RimWorkflowDateBinding::toJsonValue() const { - return hasValue() ? yamlQuotedScalar( m_value().toString( Qt::ISODate ) ) : "null"; + return hasValue() ? QJsonValue( m_value().toString( Qt::ISODate ) ) : QJsonValue( QJsonValue::Null ); } diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDateBinding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDateBinding.h index f9e88fd3da..4ae90472f2 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDateBinding.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDateBinding.h @@ -29,8 +29,8 @@ class RimWorkflowDateBinding : public RimWorkflowFieldBinding public: RimWorkflowDateBinding(); - void applySchema( const QJsonObject& fieldSchema ) override; - QString toYamlValue() const override; + void applySchema( const QJsonObject& fieldSchema ) override; + QJsonValue toJsonValue() const override; caf::PdmFieldHandle* valueField() override { return &m_value; } diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDefinition.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDefinition.cpp new file mode 100644 index 0000000000..df4e1d8e3d --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDefinition.cpp @@ -0,0 +1,125 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RimWorkflowDefinition.h" + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RimWorkflowDefinitionEdge::isWholeInput() const +{ + return input.isEmpty(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RimWorkflowDefinitionEdge::isWholeOutput() const +{ + return output.isEmpty(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +const RimWorkflowDefinitionNode* RimWorkflowDefinition::findNode( const QString& nodeName ) const +{ + for ( const auto& node : nodes ) + { + if ( node.name == nodeName ) return &node; + } + return nullptr; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RimWorkflowDefinitionNode* RimWorkflowDefinition::findNode( const QString& nodeName ) +{ + for ( auto& node : nodes ) + { + if ( node.name == nodeName ) return &node; + } + return nullptr; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +int RimWorkflowDefinition::nodeIndex( const QString& nodeName ) const +{ + for ( size_t i = 0; i < nodes.size(); ++i ) + { + if ( nodes[i].name == nodeName ) return static_cast( i ); + } + return -1; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QJsonObject RimWorkflowDefinition::taskType( const QString& taskId ) const +{ + return taskTypes.value( taskId ).toObject(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QJsonObject RimWorkflowDefinition::taskTypeForNode( const QString& nodeName ) const +{ + const auto* node = findNode( nodeName ); + return node ? taskType( node->taskId ) : QJsonObject(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +std::vector RimWorkflowDefinition::incomingEdges( const QString& nodeName ) const +{ + std::vector result; + for ( const auto& edge : edges ) + { + if ( edge.to == nodeName ) result.push_back( edge ); + } + return result; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +std::vector RimWorkflowDefinition::outgoingEdges( const QString& nodeName ) const +{ + std::vector result; + for ( const auto& edge : edges ) + { + if ( edge.from == nodeName ) result.push_back( edge ); + } + return result; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QStringList RimWorkflowDefinition::nodeNames() const +{ + QStringList names; + for ( const auto& node : nodes ) + names.append( node.name ); + return names; +} diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDefinition.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDefinition.h new file mode 100644 index 0000000000..80ba45698e --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDefinition.h @@ -0,0 +1,120 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 +#include +#include + +#include + +//================================================================================================== +/// One task instance in a workflow +//================================================================================================== +struct RimWorkflowDefinitionNode +{ + QString name{}; + QString taskId{}; + QStringList configFields{}; + + // Fields the user wants to configure even when they are optional or cannot be derived + QStringList explicitConfigFields{}; + + // Keys of the YAML entry the editor does not model (nested workflow, map); kept for read-only display + QJsonObject extra{}; + + bool operator==( const RimWorkflowDefinitionNode& other ) const = default; +}; + +//================================================================================================== +/// A connection between two tasks. An empty output or input means the whole model. +//================================================================================================== +struct RimWorkflowDefinitionEdge +{ + QString from{}; + QString output{}; + QString to{}; + QString input{}; + + bool isWholeInput() const; + bool isWholeOutput() const; + + bool operator==( const RimWorkflowDefinitionEdge& other ) const = default; +}; + +//================================================================================================== +/// A problem found by static validation or by `taskmaestro workflow describe` +//================================================================================================== +struct RimWorkflowIssue +{ + enum class Severity + { + Error, + Warning + }; + + QString task{}; + QString field{}; + Severity severity = Severity::Error; + QString message{}; + + bool operator==( const RimWorkflowIssue& other ) const = default; +}; + +//================================================================================================== +/// Editable model of a taskmaestro workflow, exchanged as JSON with `rips.taskmaestro_helper`. +/// A plain value type: copies are cheap enough to serve as undo snapshots. +//================================================================================================== +struct RimWorkflowDefinition +{ + int format = 1; + QString name; + QString resultTask; + QString headerComment; + QJsonObject passthrough; + QJsonObject source; + + std::vector nodes; + std::vector edges; + + // Task instance name -> { field: value } + QJsonObject inputs; + + // Task id -> descriptor { id, name, python_type, description, input_schema, output_schema } + QJsonObject taskTypes; + + bool editable = true; + QStringList readOnlyReasons; + QStringList warnings; + + QJsonObject describe; + QJsonObject describeError; + + const RimWorkflowDefinitionNode* findNode( const QString& nodeName ) const; + RimWorkflowDefinitionNode* findNode( const QString& nodeName ); + int nodeIndex( const QString& nodeName ) const; + QJsonObject taskType( const QString& taskId ) const; + QJsonObject taskTypeForNode( const QString& nodeName ) const; + std::vector incomingEdges( const QString& nodeName ) const; + std::vector outgoingEdges( const QString& nodeName ) const; + QStringList nodeNames() const; + + bool operator==( const RimWorkflowDefinition& other ) const = default; +}; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDefinitionTools.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDefinitionTools.cpp new file mode 100644 index 0000000000..82e68561e3 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDefinitionTools.cpp @@ -0,0 +1,887 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RimWorkflowDefinitionTools.h" + +#include "RimWorkflowPortCompatibility.h" +#include "RimWorkflowSchemaTools.h" +#include "RimWorkflowTaskCatalog.h" + +#include +#include +#include + +namespace +{ +const QString explicitConfigKey = "x_ri_explicit_config_fields"; + +QStringList stringList( const QJsonValue& value ) +{ + QStringList result; + for ( const QJsonValue& item : value.toArray() ) + { + if ( item.isString() ) result.append( item.toString() ); + } + return result; +} + +QJsonValue nullIfEmpty( const QString& text ) +{ + return text.isEmpty() ? QJsonValue( QJsonValue::Null ) : QJsonValue( text ); +} + +RimWorkflowDefinitionNode nodeFromJson( const QJsonObject& json ) +{ + RimWorkflowDefinitionNode node; + node.name = json.value( "name" ).toString(); + node.taskId = json.value( "task" ).toString(); + node.configFields = stringList( json.value( "config_fields" ) ); + node.explicitConfigFields = json.contains( explicitConfigKey ) ? stringList( json.value( explicitConfigKey ) ) : node.configFields; + for ( auto it = json.begin(); it != json.end(); ++it ) + { + if ( it.key() != "name" && it.key() != "task" && it.key() != "config_fields" && it.key() != explicitConfigKey ) + node.extra.insert( it.key(), it.value() ); + } + return node; +} + +QJsonObject nodeToJson( const RimWorkflowDefinitionNode& node ) +{ + QJsonObject json = node.extra; + json["name"] = node.name; + json["task"] = node.taskId; + json["config_fields"] = QJsonArray::fromStringList( node.configFields ); + json[explicitConfigKey] = QJsonArray::fromStringList( node.explicitConfigFields ); + return json; +} + +QJsonObject edgeToJson( const RimWorkflowDefinitionEdge& edge ) +{ + return QJsonObject{ { "from", edge.from }, + { "output", nullIfEmpty( edge.output ) }, + { "to", edge.to }, + { "input", nullIfEmpty( edge.input ) } }; +} + +QJsonObject issueToJson( const RimWorkflowIssue& issue ) +{ + return QJsonObject{ { "task", issue.task }, + { "field", issue.field }, + { "severity", issue.severity == RimWorkflowIssue::Severity::Error ? "error" : "warning" }, + { "message", issue.message } }; +} + +void addIssue( std::vector& issues, const QString& task, const QString& field, const QString& message, bool isError = true ) +{ + RimWorkflowIssue issue{ .task = task, + .field = field, + .severity = isError ? RimWorkflowIssue::Severity::Error : RimWorkflowIssue::Severity::Warning, + .message = message }; + if ( std::find( issues.begin(), issues.end(), issue ) == issues.end() ) issues.push_back( issue ); +} + +bool hasCycle( const RimWorkflowDefinition& definition ) +{ + QMap inDegree; + for ( const auto& node : definition.nodes ) + inDegree[node.name] = 0; + for ( const auto& edge : definition.edges ) + { + if ( inDegree.contains( edge.to ) && inDegree.contains( edge.from ) ) inDegree[edge.to]++; + } + + QStringList ready; + for ( auto it = inDegree.begin(); it != inDegree.end(); ++it ) + { + if ( it.value() == 0 ) ready.append( it.key() ); + } + int visited = 0; + while ( !ready.isEmpty() ) + { + const QString current = ready.takeLast(); + ++visited; + for ( const auto& edge : definition.edges ) + { + if ( edge.from != current || !inDegree.contains( edge.to ) ) continue; + if ( --inDegree[edge.to] == 0 ) ready.append( edge.to ); + } + } + return visited != inDegree.size(); +} + +void validateConnections( const RimWorkflowDefinition& definition, const RimWorkflowDefinitionNode& node, std::vector& issues ) +{ + const auto incoming = definition.incomingEdges( node.name ); + int wholeCount = 0; + QSet fieldInputs; + for ( const auto& edge : incoming ) + { + if ( edge.isWholeInput() ) + { + ++wholeCount; + continue; + } + if ( fieldInputs.contains( edge.input ) ) + addIssue( issues, node.name, edge.input, QString( "Input '%1' has more than one connection" ).arg( edge.input ) ); + fieldInputs.insert( edge.input ); + } + if ( wholeCount > 1 ) addIssue( issues, node.name, {}, "The whole input has more than one connection" ); + if ( wholeCount > 0 && !fieldInputs.isEmpty() ) + addIssue( issues, node.name, {}, "The task takes both its whole input and individual input fields from other tasks" ); + if ( wholeCount > 1 || ( wholeCount > 0 && !fieldInputs.isEmpty() ) ) return; + + for ( const auto& edge : incoming ) + { + if ( !definition.findNode( edge.from ) ) continue; + RimWorkflowDefinition others = definition; + std::erase( others.edges, edge ); + auto check = RimWorkflowPortCompatibility::canConnect( others, edge.from, edge.output, edge.to, edge.input ); + if ( !check ) addIssue( issues, node.name, edge.input, check.error() ); + } +} + +void validateInputs( const RimWorkflowDefinition& definition, const RimWorkflowDefinitionNode& node, std::vector& issues ) +{ + const QJsonObject taskType = definition.taskType( node.taskId ); + const QStringList covered = RimWorkflowDefinitionTools::coveredInputFields( definition, node.name ); + const auto incoming = definition.incomingEdges( node.name ); + + bool hasFieldIssue = false; + for ( const auto& port : RimWorkflowPortCompatibility::inputPorts( taskType ) ) + { + if ( port.isWhole() || !port.required || covered.contains( port.name ) || node.configFields.contains( port.name ) ) continue; + const QString message = port.configurable ? QString( "Input '%1' is required; connect or configure it" ).arg( port.name ) + : QString( "Input '%1' (%2) must be connected" ).arg( port.name, port.typeName ); + addIssue( issues, node.name, port.name, message ); + hasFieldIssue = true; + } + + const QJsonObject inputSchema = taskType.value( "input_schema" ).toObject(); + const bool hasFields = !inputSchema.value( "properties" ).toObject().isEmpty(); + if ( incoming.empty() && hasFields && node.configFields.isEmpty() && !hasFieldIssue ) + { + const QString message = + RimWorkflowPortCompatibility::inputPorts( taskType ).size() > 1 + ? QString( "No inputs are connected or configured; configure an optional input or connect the task" ) + : QString( "The input (%1) must be connected" ).arg( RimWorkflowPortCompatibility::typeName( inputSchema, inputSchema ) ); + addIssue( issues, node.name, {}, message ); + } + + const QJsonObject properties = inputSchema.value( "properties" ).toObject(); + for ( const QString& field : node.configFields ) + { + if ( !properties.contains( field ) ) + addIssue( issues, node.name, field, QString( "Configured field '%1' is not an input of the task" ).arg( field ) ); + } +} + +RimWorkflowDefinition withoutInvalidReferences( RimWorkflowDefinition definition ) +{ + const QStringList names = definition.nodeNames(); + std::erase_if( definition.edges, + [&names]( const RimWorkflowDefinitionEdge& edge ) { return !names.contains( edge.from ) || !names.contains( edge.to ); } ); + if ( !definition.resultTask.isEmpty() && !names.contains( definition.resultTask ) ) definition.resultTask.clear(); + + QJsonObject inputs; + for ( const QString& name : names ) + inputs.insert( name, definition.inputs.value( name ).toObject() ); + definition.inputs = inputs; + return definition; +} + +QJsonArray inputPortsJson( const RimWorkflowDefinition& definition, + const RimWorkflowDefinitionNode& node, + const std::vector& ports, + const std::vector& incoming ) +{ + const QStringList covered = RimWorkflowDefinitionTools::coveredInputFields( definition, node.name ); + QJsonArray result; + for ( const auto& port : ports ) + { + bool wired = false; + for ( const auto& edge : incoming ) + wired = wired || edge.input == port.name; + result.append( QJsonObject{ { "name", port.name }, + { "type", port.typeName }, + { "description", port.description }, + { "required", port.required }, + { "configurable", port.configurable }, + { "configured", !port.isWhole() && node.configFields.contains( port.name ) }, + { "covered", !port.isWhole() && covered.contains( port.name ) }, + { "wired", wired } } ); + } + return result; +} + +QJsonArray outputPortsJson( const std::vector& ports, const std::vector& outgoing ) +{ + QJsonArray result; + for ( const auto& port : ports ) + { + bool wired = false; + for ( const auto& edge : outgoing ) + wired = wired || edge.output == port.name; + result.append( + QJsonObject{ { "name", port.name }, { "type", port.typeName }, { "description", port.description }, { "wired", wired } } ); + } + return result; +} + +QJsonObject graphTask( const RimWorkflowDefinition& definition, + const RimWorkflowDefinitionNode& node, + const QString& resultTask, + const std::vector& issues ) +{ + const QJsonObject taskType = definition.taskType( node.taskId ); + const QJsonObject inputSchema = taskType.value( "input_schema" ).toObject(); + const auto inputPorts = RimWorkflowPortCompatibility::inputPorts( taskType ); + const auto outputPorts = RimWorkflowPortCompatibility::outputPorts( taskType ); + const QStringList required = RimWorkflowSchemaTools::requiredFields( inputSchema ); + const QJsonObject configValues = definition.inputs.value( node.name ).toObject(); + + QJsonArray inputs; + for ( const auto& port : inputPorts ) + { + if ( !port.isWhole() ) inputs.append( port.name ); + } + QJsonArray outputs; + for ( const auto& port : outputPorts ) + { + if ( !port.isWhole() ) outputs.append( port.name ); + } + QJsonArray configFields; + for ( const QString& field : node.configFields ) + configFields.append( RimWorkflowSchemaTools::configFieldSchema( field, inputSchema, required, configValues ) ); + + QJsonArray taskIssues; + for ( const auto& issue : issues ) + { + if ( issue.task == node.name ) taskIssues.append( issueToJson( issue ) ); + } + + return QJsonObject{ { "name", node.name }, + { "task_id", node.taskId }, + { "task_name", taskType.value( "name" ).toString() }, + { "description", taskType.value( "description" ).toString() }, + { "known_type", !taskType.isEmpty() }, + { "inputs", inputs }, + { "outputs", outputs }, + { "config_fields", configFields }, + { "accepts_input", !inputSchema.value( "properties" ).toObject().isEmpty() }, + { "whole_input", inputPorts.size() == 1 && !inputSchema.value( "properties" ).toObject().isEmpty() }, + { "input_ports", inputPortsJson( definition, node, inputPorts, definition.incomingEdges( node.name ) ) }, + { "output_ports", outputPortsJson( outputPorts, definition.outgoingEdges( node.name ) ) }, + { "is_result", node.name == resultTask }, + { "issues", taskIssues } }; +} +} // namespace + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +std::expected RimWorkflowDefinitionTools::fromJson( const QJsonObject& json ) +{ + if ( json.value( "status" ).toString() == "invalid" ) + { + const QString message = json.value( "error" ).toObject().value( "message" ).toString(); + return std::unexpected( message.isEmpty() ? QString( "Invalid workflow definition" ) : message ); + } + if ( json.contains( "format" ) && json.value( "format" ).toInt() != 1 ) + return std::unexpected( QString( "Unsupported workflow definition format %1" ).arg( json.value( "format" ).toInt() ) ); + if ( !json.value( "nodes" ).isArray() ) return std::unexpected( "The workflow definition has no task list" ); + + RimWorkflowDefinition definition; + definition.name = json.value( "name" ).toString(); + definition.resultTask = json.value( "result_task" ).toString(); + definition.headerComment = json.value( "header_comment" ).toString(); + definition.passthrough = json.value( "passthrough" ).toObject(); + definition.source = json.value( "source" ).toObject(); + definition.inputs = json.value( "inputs" ).toObject(); + definition.taskTypes = json.value( "task_types" ).toObject(); + definition.editable = json.value( "editable" ).toBool( true ); + definition.readOnlyReasons = stringList( json.value( "readonly_reasons" ) ); + definition.warnings = stringList( json.value( "warnings" ) ); + definition.describe = json.value( "describe" ).toObject(); + definition.describeError = json.value( "describe_error" ).toObject(); + + for ( const QJsonValue& value : json.value( "nodes" ).toArray() ) + { + auto node = nodeFromJson( value.toObject() ); + if ( node.name.isEmpty() || node.taskId.isEmpty() ) + return std::unexpected( "A task in the workflow definition has no name or type" ); + definition.nodes.push_back( node ); + } + + for ( const QJsonValue& value : json.value( "edges" ).toArray() ) + { + const QJsonObject edge = value.toObject(); + definition.edges.push_back( { .from = edge.value( "from" ).toString(), + .output = edge.value( "output" ).toString(), + .to = edge.value( "to" ).toString(), + .input = edge.value( "input" ).toString() } ); + } + return definition; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QJsonObject RimWorkflowDefinitionTools::toJson( const RimWorkflowDefinition& definition ) +{ + QJsonArray nodes; + for ( const auto& node : definition.nodes ) + nodes.append( nodeToJson( node ) ); + QJsonArray edges; + for ( const auto& edge : definition.edges ) + edges.append( edgeToJson( edge ) ); + + return QJsonObject{ { "format", definition.format }, + { "name", definition.name }, + { "result_task", nullIfEmpty( definition.resultTask ) }, + { "editable", definition.editable }, + { "readonly_reasons", QJsonArray::fromStringList( definition.readOnlyReasons ) }, + { "warnings", QJsonArray::fromStringList( definition.warnings ) }, + { "source", definition.source }, + { "header_comment", definition.headerComment }, + { "passthrough", definition.passthrough }, + { "nodes", nodes }, + { "edges", edges }, + { "inputs", definition.inputs }, + { "task_types", definition.taskTypes }, + { "describe", definition.describe.isEmpty() ? QJsonValue( QJsonValue::Null ) : QJsonValue( definition.describe ) }, + { "describe_error", + definition.describeError.isEmpty() ? QJsonValue( QJsonValue::Null ) : QJsonValue( definition.describeError ) } }; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RimWorkflowDefinition RimWorkflowDefinitionTools::createEmpty( const QString& name ) +{ + RimWorkflowDefinition definition; + definition.name = name; + definition.headerComment = "# Created in ResInsight"; + return definition; +} + +//-------------------------------------------------------------------------------------------------- +/// A copy that is saved as a new workflow. The source folder is kept for workflow-local modules. +//-------------------------------------------------------------------------------------------------- +RimWorkflowDefinition RimWorkflowDefinitionTools::editableCopy( const RimWorkflowDefinition& definition, const QString& name ) +{ + RimWorkflowDefinition copy = definition; + copy.name = name; + copy.describe = {}; + copy.describeError = {}; + copy.warnings.clear(); + if ( copy.headerComment.isEmpty() ) copy.headerComment = QString( "# Copy of %1, created in ResInsight" ).arg( definition.name ); + return copy; +} + +//-------------------------------------------------------------------------------------------------- +/// Parts of the workflow the editor does not model (nested workflows, `map:`). A copy of such a +/// workflow stays read-only. +//-------------------------------------------------------------------------------------------------- +QStringList RimWorkflowDefinitionTools::structuralReadOnlyReasons( const RimWorkflowDefinition& definition ) +{ + QStringList reasons; + for ( const auto& node : definition.nodes ) + { + if ( !node.extra.isEmpty() ) + reasons << QString( "Task '%1' uses '%2', which the editor does not support" ).arg( node.name, node.extra.keys().join( "', '" ) ); + } + return reasons; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RimWorkflowDefinitionTools::isIdentifier( const QString& name ) +{ + static const QRegularExpression pattern( "^[A-Za-z_][A-Za-z0-9_]*$" ); + return pattern.match( name ).hasMatch(); +} + +//-------------------------------------------------------------------------------------------------- +/// Turn arbitrary text (a task id, a folder name) into a Python identifier +//-------------------------------------------------------------------------------------------------- +QString RimWorkflowDefinitionTools::identifierFrom( const QString& text ) +{ + QString result; + for ( const QChar c : text ) + { + const bool valid = ( c >= 'a' && c <= 'z' ) || ( c >= 'A' && c <= 'Z' ) || ( c >= '0' && c <= '9' ) || c == '_'; + result += valid ? c : QChar( '_' ); + } + if ( result.isEmpty() || result.front().isDigit() ) result.prepend( '_' ); + return result; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QString RimWorkflowDefinitionTools::uniqueInstanceName( const RimWorkflowDefinition& definition, const QString& baseName ) +{ + const QString base = identifierFrom( baseName ); + if ( !definition.findNode( base ) ) return base; + for ( int index = 2;; ++index ) + { + const QString candidate = QString( "%1_%2" ).arg( base ).arg( index ); + if ( !definition.findNode( candidate ) ) return candidate; + } +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +std::expected + RimWorkflowDefinitionTools::validateInstanceName( const RimWorkflowDefinition& definition, const QString& name, const QString& currentName ) +{ + if ( !isIdentifier( name ) ) + return std::unexpected( QString( "'%1' is not a valid task name; use letters, digits and underscores" ).arg( name ) ); + if ( name != currentName && definition.findNode( name ) ) + return std::unexpected( QString( "A task named '%1' already exists" ).arg( name ) ); + for ( const auto& node : definition.nodes ) + { + if ( node.taskId == name ) return std::unexpected( QString( "'%1' is the id of a task type" ).arg( name ) ); + } + return {}; +} + +//-------------------------------------------------------------------------------------------------- +/// Tasks without downstream tasks, in definition order +//-------------------------------------------------------------------------------------------------- +QStringList RimWorkflowDefinitionTools::sinkTasks( const RimWorkflowDefinition& definition ) +{ + QStringList sinks; + for ( const auto& node : definition.nodes ) + { + if ( definition.outgoingEdges( node.name ).empty() ) sinks.append( node.name ); + } + return sinks; +} + +//-------------------------------------------------------------------------------------------------- +/// The explicit result task, or the only sink. Empty when ambiguous. +//-------------------------------------------------------------------------------------------------- +QString RimWorkflowDefinitionTools::effectiveResultTask( const RimWorkflowDefinition& definition ) +{ + if ( !definition.resultTask.isEmpty() ) return definition.resultTask; + const QStringList sinks = sinkTasks( definition ); + return sinks.size() == 1 ? sinks.front() : QString(); +} + +//-------------------------------------------------------------------------------------------------- +/// Input fields that are supplied by connections +//-------------------------------------------------------------------------------------------------- +QStringList RimWorkflowDefinitionTools::coveredInputFields( const RimWorkflowDefinition& definition, const QString& nodeName ) +{ + const QJsonObject taskType = definition.taskTypeForNode( nodeName ); + QStringList covered; + for ( const auto& edge : definition.incomingEdges( nodeName ) ) + { + if ( !edge.isWholeInput() ) + { + covered.append( edge.input ); + continue; + } + + // A routed output field becomes the whole input value + if ( !edge.isWholeOutput() ) + { + covered.append( RimWorkflowSchemaTools::propertyNames( taskType.value( "input_schema" ).toObject() ) ); + continue; + } + covered.append( RimWorkflowPortCompatibility::coveredByWholeOutput( definition.taskTypeForNode( edge.from ), taskType ) ); + } + covered.removeDuplicates(); + return covered; +} + +//-------------------------------------------------------------------------------------------------- +/// Config fields are the inputs that are not connected: required ones that can be entered in +/// ResInsight, and those the user explicitly configured. A routed output field allows no config. +//-------------------------------------------------------------------------------------------------- +QStringList RimWorkflowDefinitionTools::deriveConfigFields( const RimWorkflowDefinition& definition, const QString& nodeName ) +{ + const auto* node = definition.findNode( nodeName ); + if ( !node ) return {}; + const QJsonObject taskType = definition.taskType( node->taskId ); + if ( taskType.isEmpty() ) return node->configFields; + + for ( const auto& edge : definition.incomingEdges( nodeName ) ) + { + if ( edge.isWholeInput() && !edge.isWholeOutput() ) return {}; + } + + const QJsonObject inputSchema = taskType.value( "input_schema" ).toObject(); + const QJsonObject properties = inputSchema.value( "properties" ).toObject(); + const QStringList required = RimWorkflowSchemaTools::requiredFields( inputSchema ); + const QStringList covered = coveredInputFields( definition, nodeName ); + + QStringList derived; + for ( const QString& field : RimWorkflowSchemaTools::propertyNames( inputSchema ) ) + { + if ( covered.contains( field ) ) continue; + const bool configurable = RimWorkflowSchemaTools::isConfigurable( properties.value( field ).toObject(), inputSchema ); + if ( node->explicitConfigFields.contains( field ) || ( required.contains( field ) && configurable ) ) derived.append( field ); + } + + // Keep the existing order and append new fields + QStringList result; + for ( const QString& field : node->configFields ) + { + if ( derived.contains( field ) ) result.append( field ); + } + for ( const QString& field : derived ) + { + if ( !result.contains( field ) ) result.append( field ); + } + return result; +} + +//-------------------------------------------------------------------------------------------------- +/// Remove dangling references and derive the config fields of every task +//-------------------------------------------------------------------------------------------------- +RimWorkflowDefinition RimWorkflowDefinitionTools::normalize( RimWorkflowDefinition definition ) +{ + if ( !definition.editable ) return definition; + + definition = withoutInvalidReferences( definition ); + + std::vector configFields; + for ( const auto& node : definition.nodes ) + configFields.push_back( deriveConfigFields( definition, node.name ) ); + for ( size_t i = 0; i < definition.nodes.size(); ++i ) + definition.nodes[i].configFields = configFields[i]; + return definition; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RimWorkflowDefinitionTools::EditResult RimWorkflowDefinitionTools::addTask( const RimWorkflowDefinition& definition, + const RimWorkflowTaskCatalog& catalog, + const QString& taskId, + QString* addedName ) +{ + if ( !definition.editable ) return std::unexpected( "The workflow is read-only" ); + + QJsonObject descriptor = catalog.taskType( taskId ); + if ( descriptor.isEmpty() ) descriptor = definition.taskType( taskId ); + if ( descriptor.isEmpty() ) return std::unexpected( QString( "Unknown task type '%1'" ).arg( taskId ) ); + + QString baseName = descriptor.value( "name" ).toString(); + if ( baseName.isEmpty() ) baseName = taskId.mid( taskId.lastIndexOf( '.' ) + 1 ); + + RimWorkflowDefinition result = definition; + const QString name = uniqueInstanceName( result, baseName ); + result.nodes.push_back( { .name = name, .taskId = taskId } ); + result.taskTypes.insert( taskId, descriptor ); + result.inputs.insert( name, QJsonObject() ); + result = autoWireResInsightInputs( result, catalog, name ); + + if ( addedName ) *addedName = name; + return normalize( result ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RimWorkflowDefinitionTools::EditResult RimWorkflowDefinitionTools::removeTask( const RimWorkflowDefinition& definition, const QString& nodeName ) +{ + if ( !definition.editable ) return std::unexpected( "The workflow is read-only" ); + if ( !definition.findNode( nodeName ) ) return std::unexpected( QString( "Unknown task '%1'" ).arg( nodeName ) ); + + RimWorkflowDefinition result = definition; + std::erase_if( result.nodes, [&nodeName]( const RimWorkflowDefinitionNode& node ) { return node.name == nodeName; } ); + return normalize( result ); +} + +//-------------------------------------------------------------------------------------------------- +/// Add a connection. An existing connection to the same input is replaced. +//-------------------------------------------------------------------------------------------------- +RimWorkflowDefinitionTools::EditResult RimWorkflowDefinitionTools::connect( const RimWorkflowDefinition& definition, + const RimWorkflowDefinitionEdge& edge ) +{ + if ( !definition.editable ) return std::unexpected( "The workflow is read-only" ); + + auto check = RimWorkflowPortCompatibility::canConnect( definition, edge.from, edge.output, edge.to, edge.input ); + if ( !check ) return std::unexpected( check.error() ); + + RimWorkflowDefinition result = definition; + std::erase_if( result.edges, + [&edge]( const RimWorkflowDefinitionEdge& existing ) { return existing.to == edge.to && existing.input == edge.input; } ); + result.edges.push_back( edge ); + return normalize( result ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RimWorkflowDefinitionTools::EditResult RimWorkflowDefinitionTools::disconnect( const RimWorkflowDefinition& definition, + const RimWorkflowDefinitionEdge& edge ) +{ + if ( !definition.editable ) return std::unexpected( "The workflow is read-only" ); + + RimWorkflowDefinition result = definition; + if ( std::erase( result.edges, edge ) == 0 ) return std::unexpected( "The connection does not exist" ); + return normalize( result ); +} + +//-------------------------------------------------------------------------------------------------- +/// Rename a task instance, updating connections, the result task and the input values +//-------------------------------------------------------------------------------------------------- +RimWorkflowDefinitionTools::EditResult + RimWorkflowDefinitionTools::renameTask( const RimWorkflowDefinition& definition, const QString& oldName, const QString& newName ) +{ + if ( !definition.editable ) return std::unexpected( "The workflow is read-only" ); + if ( !definition.findNode( oldName ) ) return std::unexpected( QString( "Unknown task '%1'" ).arg( oldName ) ); + if ( oldName == newName ) return definition; + if ( auto valid = validateInstanceName( definition, newName, oldName ); !valid ) return std::unexpected( valid.error() ); + + RimWorkflowDefinition result = definition; + result.findNode( oldName )->name = newName; + for ( auto& edge : result.edges ) + { + if ( edge.from == oldName ) edge.from = newName; + if ( edge.to == oldName ) edge.to = newName; + } + if ( result.resultTask == oldName ) result.resultTask = newName; + + const QJsonValue values = result.inputs.take( oldName ); + result.inputs.insert( newName, values.isObject() ? values : QJsonObject() ); + return normalize( result ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RimWorkflowDefinitionTools::EditResult RimWorkflowDefinitionTools::renameWorkflow( const RimWorkflowDefinition& definition, + const QString& newName ) +{ + if ( !definition.editable ) return std::unexpected( "The workflow is read-only" ); + if ( !isIdentifier( newName ) ) + return std::unexpected( QString( "'%1' is not a valid workflow name; use letters, digits and underscores" ).arg( newName ) ); + + RimWorkflowDefinition result = definition; + result.name = newName; + return result; +} + +//-------------------------------------------------------------------------------------------------- +/// Set the explicit result task. An empty name clears it (the only sink is used). +//-------------------------------------------------------------------------------------------------- +RimWorkflowDefinitionTools::EditResult RimWorkflowDefinitionTools::setResultTask( const RimWorkflowDefinition& definition, + const QString& nodeName ) +{ + if ( !definition.editable ) return std::unexpected( "The workflow is read-only" ); + if ( !nodeName.isEmpty() && !definition.findNode( nodeName ) ) return std::unexpected( QString( "Unknown task '%1'" ).arg( nodeName ) ); + + RimWorkflowDefinition result = definition; + result.resultTask = nodeName; + return result; +} + +//-------------------------------------------------------------------------------------------------- +/// Choose whether an optional input that is not connected is a config field +//-------------------------------------------------------------------------------------------------- +RimWorkflowDefinitionTools::EditResult RimWorkflowDefinitionTools::setOptionalInputConfigured( const RimWorkflowDefinition& definition, + const QString& nodeName, + const QString& field, + bool configured ) +{ + if ( !definition.editable ) return std::unexpected( "The workflow is read-only" ); + const auto* node = definition.findNode( nodeName ); + if ( !node ) return std::unexpected( QString( "Unknown task '%1'" ).arg( nodeName ) ); + + const auto port = RimWorkflowPortCompatibility::inputPort( definition.taskType( node->taskId ), field ); + if ( !port || port->isWhole() ) return std::unexpected( QString( "'%1' has no input '%2'" ).arg( nodeName, field ) ); + if ( port->required ) return std::unexpected( QString( "Input '%1' is required and always configured unless connected" ).arg( field ) ); + + RimWorkflowDefinition result = definition; + QStringList& fields = result.findNode( nodeName )->explicitConfigFields; + fields.removeAll( field ); + if ( configured ) fields.append( field ); + return normalize( result ); +} + +//-------------------------------------------------------------------------------------------------- +/// Connect inputs that take a ResInsight connection to a `resinsight.connect` task, which is +/// created if the workflow has none +//-------------------------------------------------------------------------------------------------- +RimWorkflowDefinition RimWorkflowDefinitionTools::autoWireResInsightInputs( const RimWorkflowDefinition& definition, + const RimWorkflowTaskCatalog& catalog, + const QString& nodeName ) +{ + const auto* node = definition.findNode( nodeName ); + if ( !node || node->taskId == connectTaskId ) return definition; + + QJsonObject connectType = catalog.taskType( connectTaskId ); + if ( connectType.isEmpty() ) connectType = definition.taskType( connectTaskId ); + if ( connectType.isEmpty() ) return definition; + + const QJsonObject connectOutput = connectType.value( "output_schema" ).toObject(); + if ( !connectOutput.contains( "x-ri-python-type" ) ) return definition; + const RimWorkflowPort produced{ .schema = connectOutput, .rootSchema = connectOutput }; + + const auto incoming = definition.incomingEdges( nodeName ); + QStringList wiredInputs; + for ( const auto& edge : incoming ) + wiredInputs.append( edge.input ); + + // Only inputs declared with the connection type; untyped (Any) inputs are left alone + auto takesConnection = [&produced]( const RimWorkflowPort& port ) + { + const QJsonObject resolved = RimWorkflowSchemaTools::resolveReferences( port.schema, port.rootSchema ); + return port.required && resolved.contains( "x-ri-python-type" ) && RimWorkflowPortCompatibility::isCompatible( produced, port ); + }; + + QStringList inputsToWire; + const auto ports = RimWorkflowPortCompatibility::inputPorts( definition.taskType( node->taskId ) ); + for ( const auto& port : ports ) + { + if ( port.isWhole() || wiredInputs.contains( port.name ) || !takesConnection( port ) ) continue; + inputsToWire.append( port.name ); + } + const bool wireWhole = inputsToWire.isEmpty() && incoming.empty() && ports.size() == 1 && takesConnection( ports.front() ); + if ( inputsToWire.isEmpty() && !wireWhole ) return definition; + + RimWorkflowDefinition result = definition; + QString connectName; + for ( const auto& candidate : result.nodes ) + { + if ( candidate.taskId == connectTaskId ) + { + connectName = candidate.name; + break; + } + } + if ( connectName.isEmpty() ) + { + connectName = uniqueInstanceName( result, connectType.value( "name" ).toString( "connect_to_resinsight" ) ); + result.nodes.insert( result.nodes.begin(), { .name = connectName, .taskId = connectTaskId } ); + result.taskTypes.insert( connectTaskId, connectType ); + result.inputs.insert( connectName, QJsonObject() ); + } + + if ( wireWhole ) result.edges.push_back( { .from = connectName, .to = nodeName } ); + for ( const QString& input : inputsToWire ) + result.edges.push_back( { .from = connectName, .to = nodeName, .input = input } ); + return result; +} + +//-------------------------------------------------------------------------------------------------- +/// Static checks that do not need Python. `taskmaestro workflow describe` has the final say. +//-------------------------------------------------------------------------------------------------- +std::vector RimWorkflowDefinitionTools::validate( const RimWorkflowDefinition& definition ) +{ + std::vector issues; + if ( !isIdentifier( definition.name ) ) + addIssue( issues, {}, {}, QString( "'%1' is not a valid workflow name" ).arg( definition.name ) ); + if ( definition.nodes.empty() ) + { + addIssue( issues, {}, {}, "The workflow has no tasks" ); + return issues; + } + + QSet names; + for ( const auto& node : definition.nodes ) + { + if ( !isIdentifier( node.name ) ) addIssue( issues, node.name, {}, QString( "'%1' is not a valid task name" ).arg( node.name ) ); + if ( names.contains( node.name ) ) addIssue( issues, node.name, {}, QString( "Duplicate task name '%1'" ).arg( node.name ) ); + names.insert( node.name ); + } + + for ( const auto& edge : definition.edges ) + { + if ( !names.contains( edge.from ) || !names.contains( edge.to ) ) + addIssue( issues, edge.to, edge.input, QString( "Connection from unknown task '%1'" ).arg( edge.from ) ); + } + if ( hasCycle( definition ) ) addIssue( issues, {}, {}, "The workflow contains a cycle" ); + + for ( const auto& node : definition.nodes ) + { + if ( definition.taskType( node.taskId ).isEmpty() ) + { + addIssue( issues, node.name, {}, QString( "Task type '%1' is not installed" ).arg( node.taskId ) ); + continue; + } + validateConnections( definition, node, issues ); + validateInputs( definition, node, issues ); + } + + if ( !definition.resultTask.isEmpty() && !names.contains( definition.resultTask ) ) + { + addIssue( issues, {}, {}, QString( "The result task '%1' does not exist" ).arg( definition.resultTask ) ); + } + else if ( definition.resultTask.isEmpty() ) + { + const QStringList sinks = sinkTasks( definition ); + if ( sinks.size() > 1 ) + addIssue( issues, + {}, + {}, + QString( "The workflow has %1 end tasks (%2); set a result task" ).arg( sinks.size() ).arg( sinks.join( ", " ) ) ); + } + return issues; +} + +//-------------------------------------------------------------------------------------------------- +/// The graph shown by RiuWorkflowGraphView, in the shape produced by graphFromDescribe() plus the +/// ports, the result task and the issues of every task +//-------------------------------------------------------------------------------------------------- +QJsonObject RimWorkflowDefinitionTools::graphFromDefinition( const RimWorkflowDefinition& definition, + bool editMode, + const std::vector& extraIssues ) +{ + std::vector issues = validate( definition ); + for ( const auto& issue : extraIssues ) + { + if ( std::find( issues.begin(), issues.end(), issue ) == issues.end() ) issues.push_back( issue ); + } + + const QString resultTask = effectiveResultTask( definition ); + QJsonArray tasks; + for ( const auto& node : definition.nodes ) + tasks.append( graphTask( definition, node, resultTask, issues ) ); + + QJsonArray edges; + for ( const auto& edge : definition.edges ) + { + QJsonObject json{ { "from", edge.from }, { "to", edge.to } }; + if ( !edge.input.isEmpty() ) json["input"] = edge.input; + if ( !edge.output.isEmpty() ) json["output"] = edge.output; + edges.append( json ); + } + + const QStringList nodeNames = definition.nodeNames(); + QJsonArray workflowIssues; + for ( const auto& issue : issues ) + { + if ( !nodeNames.contains( issue.task ) ) workflowIssues.append( issueToJson( issue ) ); + } + + return QJsonObject{ { "name", definition.name }, + { "description", definition.headerComment }, + { "editable", editMode && definition.editable }, + { "result_task", resultTask }, + { "tasks", tasks }, + { "edges", edges }, + { "issues", workflowIssues } }; +} diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDefinitionTools.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDefinitionTools.h new file mode 100644 index 0000000000..42c5ce20bd --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDefinitionTools.h @@ -0,0 +1,83 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RimWorkflowDefinition.h" + +#include +#include +#include +#include + +#include +#include + +class RimWorkflowTaskCatalog; + +//================================================================================================== +/// Pure functions on RimWorkflowDefinition: JSON conversion, config derivation, edit operations, +/// static validation and the graph JSON shown by RiuWorkflowGraphView. +/// Edit operations return the new definition, or an error message for the user. +//================================================================================================== +namespace RimWorkflowDefinitionTools +{ +using EditResult = std::expected; + +constexpr const char* connectTaskId = "resinsight.connect"; + +// Conversion +std::expected fromJson( const QJsonObject& json ); +QJsonObject toJson( const RimWorkflowDefinition& definition ); +RimWorkflowDefinition createEmpty( const QString& name ); +RimWorkflowDefinition editableCopy( const RimWorkflowDefinition& definition, const QString& name ); + +// Names +bool isIdentifier( const QString& name ); +QString identifierFrom( const QString& text ); +QString uniqueInstanceName( const RimWorkflowDefinition& definition, const QString& baseName ); +std::expected + validateInstanceName( const RimWorkflowDefinition& definition, const QString& name, const QString& currentName = {} ); +QStringList sinkTasks( const RimWorkflowDefinition& definition ); +QString effectiveResultTask( const RimWorkflowDefinition& definition ); +QStringList structuralReadOnlyReasons( const RimWorkflowDefinition& definition ); + +// Config fields +QStringList coveredInputFields( const RimWorkflowDefinition& definition, const QString& nodeName ); +QStringList deriveConfigFields( const RimWorkflowDefinition& definition, const QString& nodeName ); +RimWorkflowDefinition normalize( RimWorkflowDefinition definition ); + +// Edit operations +EditResult addTask( const RimWorkflowDefinition& definition, + const RimWorkflowTaskCatalog& catalog, + const QString& taskId, + QString* addedName = nullptr ); +EditResult removeTask( const RimWorkflowDefinition& definition, const QString& nodeName ); +EditResult connect( const RimWorkflowDefinition& definition, const RimWorkflowDefinitionEdge& edge ); +EditResult disconnect( const RimWorkflowDefinition& definition, const RimWorkflowDefinitionEdge& edge ); +EditResult renameTask( const RimWorkflowDefinition& definition, const QString& oldName, const QString& newName ); +EditResult renameWorkflow( const RimWorkflowDefinition& definition, const QString& newName ); +EditResult setResultTask( const RimWorkflowDefinition& definition, const QString& nodeName ); +EditResult setOptionalInputConfigured( const RimWorkflowDefinition& definition, const QString& nodeName, const QString& field, bool configured ); +RimWorkflowDefinition + autoWireResInsightInputs( const RimWorkflowDefinition& definition, const RimWorkflowTaskCatalog& catalog, const QString& nodeName ); + +// Validation and display +std::vector validate( const RimWorkflowDefinition& definition ); +QJsonObject graphFromDefinition( const RimWorkflowDefinition& definition, bool editMode, const std::vector& extraIssues = {} ); +} // namespace RimWorkflowDefinitionTools diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDescribeTools.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDescribeTools.cpp index fa43143831..db4b897b9c 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDescribeTools.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDescribeTools.cpp @@ -18,154 +18,14 @@ #include "RimWorkflowDescribeTools.h" +#include "RimWorkflowSchemaTools.h" + #include #include -#include #include namespace { -//-------------------------------------------------------------------------------------------------- -/// -//-------------------------------------------------------------------------------------------------- -QJsonObject resolveRef( const QString& ref, const QJsonObject& rootSchema ) -{ - const QString prefix = "#/$defs/"; - if ( !ref.startsWith( prefix ) ) return {}; - return rootSchema.value( "$defs" ).toObject().value( ref.mid( prefix.size() ) ).toObject(); -} - -//-------------------------------------------------------------------------------------------------- -/// Follow $ref and unwrap single-item allOf and Optional (anyOf with null) to the underlying type -//-------------------------------------------------------------------------------------------------- -QJsonObject underlyingSchema( QJsonObject schema, const QJsonObject& rootSchema ) -{ - constexpr int maxDepth = 16; - for ( int depth = 0; depth < maxDepth; ++depth ) - { - if ( schema.contains( "$ref" ) ) - { - schema = resolveRef( schema.value( "$ref" ).toString(), rootSchema ); - continue; - } - - const QJsonArray allOf = schema.value( "allOf" ).toArray(); - if ( allOf.size() == 1 ) - { - schema = allOf.first().toObject(); - continue; - } - - if ( schema.value( "anyOf" ).isArray() ) - { - QJsonArray nonNull; - for ( const QJsonValue& arm : schema.value( "anyOf" ).toArray() ) - { - if ( arm.toObject().value( "type" ).toString() != "null" ) nonNull.append( arm ); - } - if ( nonNull.size() == 1 ) - { - schema = nonNull.first().toObject(); - continue; - } - } - break; - } - return schema; -} - -//-------------------------------------------------------------------------------------------------- -/// -//-------------------------------------------------------------------------------------------------- -bool isOpaque( const QJsonObject& schema, const QJsonObject& rootSchema ) -{ - return underlyingSchema( schema, rootSchema ).value( "x-taskmaestro-opaque" ).toBool( false ); -} - -//-------------------------------------------------------------------------------------------------- -/// Python type of a runtime-only object, either bare or wrapped in an ObjectModel `value` field -//-------------------------------------------------------------------------------------------------- -QString objectPythonType( const QJsonObject& typeSchema, const QJsonObject& rootSchema ) -{ - if ( typeSchema.contains( "x-taskmaestro-python-type" ) ) return typeSchema.value( "x-taskmaestro-python-type" ).toString(); - - const QJsonObject valueSchema = typeSchema.value( "properties" ).toObject().value( "value" ).toObject(); - if ( valueSchema.isEmpty() ) return {}; - return underlyingSchema( valueSchema, rootSchema ).value( "x-taskmaestro-python-type" ).toString(); -} - -//-------------------------------------------------------------------------------------------------- -/// -//-------------------------------------------------------------------------------------------------- -QString jsonType( const QJsonObject& typeSchema ) -{ - const QString type = typeSchema.value( "type" ).toString(); - if ( type == "boolean" || type == "integer" || type == "number" || type == "array" ) return type; - return "string"; -} - -//-------------------------------------------------------------------------------------------------- -/// taskmaestro_resinsight.models.Vec3 is a plain pydantic model (x, y, z numbers), so it has no -/// x-taskmaestro-python-type tag; recognize it structurally instead. -//-------------------------------------------------------------------------------------------------- -bool isVec3Schema( const QJsonObject& typeSchema ) -{ - if ( typeSchema.value( "type" ).toString() != "object" ) return false; - - const QJsonObject properties = typeSchema.value( "properties" ).toObject(); - if ( properties.size() != 3 ) return false; - - for ( const QString& component : QStringList{ "x", "y", "z" } ) - { - if ( properties.value( component ).toObject().value( "type" ).toString() != "number" ) return false; - } - return true; -} - -//-------------------------------------------------------------------------------------------------- -/// -//-------------------------------------------------------------------------------------------------- -QJsonObject configFieldSchema( const QString& fieldName, - const QJsonObject& inputSchema, - const QStringList& requiredFields, - const QJsonObject& configValues ) -{ - QJsonObject entry{ { "name", fieldName } }; - - const QJsonObject properties = inputSchema.value( "properties" ).toObject(); - if ( !properties.contains( fieldName ) ) - { - entry["type"] = "string"; - entry["required"] = true; - entry["error"] = "field not found in input model"; - return entry; - } - - const QJsonObject property = properties.value( fieldName ).toObject(); - const QJsonObject typeSchema = underlyingSchema( property, inputSchema ); - QString resinsightType = RimWorkflowDescribeTools::resinsightTypeFromPythonType( objectPythonType( typeSchema, inputSchema ) ); - if ( resinsightType.isEmpty() && isVec3Schema( typeSchema ) ) resinsightType = "Vec3"; - - entry["type"] = resinsightType.isEmpty() ? jsonType( typeSchema ) : "object"; - entry["required"] = requiredFields.contains( fieldName ); - - const QString description = property.value( "description" ).toString(); - if ( !description.isEmpty() ) entry["description"] = description; - - // Prefer the value already in input.yaml; fall back to the model default - if ( configValues.contains( fieldName ) ) - entry["default"] = configValues.value( fieldName ); - else if ( property.contains( "default" ) ) - entry["default"] = property.value( "default" ); - - const QString format = typeSchema.value( "format" ).toString(); - if ( !format.isEmpty() ) entry["format"] = format; - - if ( !resinsightType.isEmpty() ) entry["resinsight_type"] = resinsightType; - - return entry; -} - //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- @@ -236,7 +96,7 @@ QJsonObject taskFromDescribe( const QJsonObject& task ) const QJsonObject outputProperties = outputSchema.value( "properties" ).toObject(); for ( auto it = outputProperties.begin(); it != outputProperties.end(); ++it ) { - if ( it.key() == "value" && isOpaque( it.value().toObject(), outputSchema ) ) continue; + if ( it.key() == "value" && RimWorkflowSchemaTools::isOpaque( it.value().toObject(), outputSchema ) ) continue; outputs.append( it.key() ); } @@ -251,7 +111,7 @@ QJsonObject taskFromDescribe( const QJsonObject& task ) { const QString fieldName = value.toString(); if ( fieldName.isEmpty() ) continue; - configFields.append( configFieldSchema( fieldName, inputSchema, requiredFields, configValues ) ); + configFields.append( RimWorkflowSchemaTools::configFieldSchema( fieldName, inputSchema, requiredFields, configValues ) ); } return QJsonObject{ { "name", task.value( "name" ).toString() }, @@ -304,17 +164,5 @@ QString RimWorkflowDescribeTools::errorFromDescribe( const QJsonObject& describe //-------------------------------------------------------------------------------------------------- QString RimWorkflowDescribeTools::resinsightTypeFromPythonType( const QString& pythonType ) { - const int separator = pythonType.lastIndexOf( '.' ); - if ( separator < 0 ) return {}; - - const QString module = pythonType.left( separator ); - if ( module != "rips" && !module.startsWith( "rips." ) ) return {}; - - static const QMap typeMap = { { "Case", "EclipseCase" }, - { "Reservoir", "EclipseCase" }, - { "EclipseCase", "EclipseCase" }, - { "WellPath", "WellPath" }, - { "View", "View" }, - { "EclipseView", "View" } }; - return typeMap.value( pythonType.mid( separator + 1 ) ); + return RimWorkflowSchemaTools::resinsightTypeFromPythonType( pythonType ); } diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFieldBinding.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFieldBinding.cpp index 3323e24def..e7d3d71509 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFieldBinding.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFieldBinding.cpp @@ -121,10 +121,9 @@ bool RimWorkflowFieldBinding::hasValue() const //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -QString RimWorkflowFieldBinding::yamlQuotedScalar( const QString& value ) +bool RimWorkflowFieldBinding::isObjectReference() const { - const QByteArray encoded = QJsonDocument( QJsonArray{ value } ).toJson( QJsonDocument::Compact ); - return QString::fromUtf8( encoded.mid( 1, encoded.size() - 2 ) ); + return false; } //-------------------------------------------------------------------------------------------------- diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFieldBinding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFieldBinding.h index c4b3e9b5bd..f571dba6a5 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFieldBinding.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFieldBinding.h @@ -21,6 +21,8 @@ #include "cafPdmField.h" #include "cafPdmObject.h" +#include + class QJsonObject; class RimWorkflowFieldBinding : public caf::PdmObject @@ -35,17 +37,17 @@ public: void setDescription( const QString& description ); void setRequired( bool required ); - virtual void applySchema( const QJsonObject& fieldSchema ); - virtual QString toYamlValue() const = 0; - virtual QString displayValue() const; + virtual void applySchema( const QJsonObject& fieldSchema ); + virtual QJsonValue toJsonValue() const = 0; + virtual bool isObjectReference() const; + 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 ); + bool hasValue() const; caf::PdmField m_fieldName; caf::PdmField m_description; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFilePathBinding.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFilePathBinding.cpp index 8fd64f7527..55dd283b25 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFilePathBinding.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFilePathBinding.cpp @@ -63,7 +63,7 @@ QString RimWorkflowFilePathBinding::displayValue() const //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -QString RimWorkflowFilePathBinding::toYamlValue() const +QJsonValue RimWorkflowFilePathBinding::toJsonValue() const { - return hasValue() ? yamlQuotedScalar( m_value().path() ) : "null"; + return hasValue() ? QJsonValue( m_value().path() ) : QJsonValue( QJsonValue::Null ); } diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFilePathBinding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFilePathBinding.h index a1eb24a941..23f6fd98be 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFilePathBinding.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFilePathBinding.h @@ -29,9 +29,9 @@ class RimWorkflowFilePathBinding : public RimWorkflowFieldBinding public: RimWorkflowFilePathBinding(); - void applySchema( const QJsonObject& fieldSchema ) override; - QString toYamlValue() const override; - QString displayValue() const override; + void applySchema( const QJsonObject& fieldSchema ) override; + QJsonValue toJsonValue() 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 9ff20dd025..ed5636281b 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFloatBinding.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFloatBinding.cpp @@ -45,7 +45,7 @@ void RimWorkflowFloatBinding::applySchema( const QJsonObject& fieldSchema ) //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -QString RimWorkflowFloatBinding::toYamlValue() const +QJsonValue RimWorkflowFloatBinding::toJsonValue() const { - return hasValue() ? QString::number( m_value(), 'g', 17 ) : "null"; + return hasValue() ? QJsonValue( m_value() ) : QJsonValue( QJsonValue::Null ); } diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFloatBinding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFloatBinding.h index c0607cc7de..f6e22a0c8b 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFloatBinding.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowFloatBinding.h @@ -27,8 +27,8 @@ class RimWorkflowFloatBinding : public RimWorkflowFieldBinding public: RimWorkflowFloatBinding(); - void applySchema( const QJsonObject& fieldSchema ) override; - QString toYamlValue() const override; + void applySchema( const QJsonObject& fieldSchema ) override; + QJsonValue toJsonValue() const override; caf::PdmFieldHandle* valueField() override { return &m_value; } diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowHelperProcess.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowHelperProcess.cpp new file mode 100644 index 0000000000..6eba032db9 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowHelperProcess.cpp @@ -0,0 +1,122 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RimWorkflowHelperProcess.h" + +#include "RimWorkflow.h" + +#include +#include +#include + +namespace +{ +constexpr int startTimeoutMs = 10000; +} // namespace + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +std::expected + RimWorkflowHelperProcess::runSync( const QStringList& args, const QByteArray& stdinData, int timeoutMs, const QString& extraPythonPath ) +{ + const QString python = RimWorkflow::findPythonExecutable(); + if ( python.isEmpty() ) return std::unexpected( "No usable Python interpreter found" ); + + QProcess process; + process.setProcessEnvironment( environment( extraPythonPath ) ); + process.start( python, helperArguments( args ) ); + if ( !process.waitForStarted( startTimeoutMs ) ) return std::unexpected( QString( "Could not launch '%1'" ).arg( python ) ); + + if ( !stdinData.isEmpty() ) process.write( stdinData ); + process.closeWriteChannel(); + + if ( !process.waitForFinished( timeoutMs ) ) + { + process.kill(); + process.waitForFinished( startTimeoutMs ); + return std::unexpected( QString( "The workflow helper timed out (%1)" ).arg( args.value( 0 ) ) ); + } + + const int exitCode = process.exitStatus() == QProcess::NormalExit ? process.exitCode() : -1; + return parseOutput( exitCode, process.readAllStandardOutput(), process.readAllStandardError() ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QStringList RimWorkflowHelperProcess::helperArguments( const QStringList& args ) +{ + return QStringList{ "-m", "rips.taskmaestro_helper" } + args; +} + +//-------------------------------------------------------------------------------------------------- +/// The process environment, with an extra folder in front of PYTHONPATH (to import workflow-local modules) +//-------------------------------------------------------------------------------------------------- +QProcessEnvironment RimWorkflowHelperProcess::environment( const QString& extraPythonPath ) +{ + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + if ( extraPythonPath.isEmpty() ) return env; + + const QString existing = env.value( "PYTHONPATH" ); + const QString path = QDir::toNativeSeparators( extraPythonPath ); + env.insert( "PYTHONPATH", existing.isEmpty() ? path : path + QDir::listSeparator() + existing ); + return env; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +std::expected + RimWorkflowHelperProcess::parseOutput( int exitCode, const QByteArray& standardOutput, const QByteArray& standardError ) +{ + QJsonParseError parseError{}; + const QJsonDocument document = QJsonDocument::fromJson( standardOutput.trimmed(), &parseError ); + if ( parseError.error == QJsonParseError::NoError && document.isObject() ) + { + const QJsonObject result = document.object(); + if ( result.value( "status" ).toString() == "invalid" ) + return std::unexpected( errorMessage( result.value( "error" ).toObject() ) ); + if ( exitCode == 0 ) return result; + } + + QString message = QString::fromUtf8( standardError ).trimmed(); + if ( message.isEmpty() ) message = QString::fromUtf8( standardOutput ).trimmed(); + // The last lines of a traceback hold the exception + const QStringList lines = message.split( '\n', Qt::SkipEmptyParts ); + if ( lines.size() > 6 ) message = lines.mid( lines.size() - 6 ).join( '\n' ); + if ( message.isEmpty() ) message = QString( "The workflow helper failed with exit code %1" ).arg( exitCode ); + return std::unexpected( message ); +} + +//-------------------------------------------------------------------------------------------------- +/// Message for an error object { message, task, field } +//-------------------------------------------------------------------------------------------------- +QString RimWorkflowHelperProcess::errorMessage( const QJsonObject& error ) +{ + QString message = error.value( "message" ).toString(); + if ( message.isEmpty() ) message = "Workflow configuration is invalid"; + + QStringList details; + const QString task = error.value( "task" ).toString(); + if ( !task.isEmpty() && !message.contains( task ) ) details.append( QString( "task '%1'" ).arg( task ) ); + const QString field = error.value( "field" ).toString(); + if ( !field.isEmpty() && !message.contains( field ) ) details.append( QString( "field '%1'" ).arg( field ) ); + if ( !details.isEmpty() ) message += " (" + details.join( ", " ) + ")"; + return message; +} diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowHelperProcess.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowHelperProcess.h new file mode 100644 index 0000000000..3badf9002c --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowHelperProcess.h @@ -0,0 +1,43 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include +#include +#include +#include + +#include + +//================================================================================================== +/// Runs `python -m rips.taskmaestro_helper `, which prints exactly one JSON document +//================================================================================================== +namespace RimWorkflowHelperProcess +{ +constexpr int defaultTimeoutMs = 60000; + +std::expected + runSync( const QStringList& args, const QByteArray& stdinData = {}, int timeoutMs = defaultTimeoutMs, const QString& extraPythonPath = {} ); + +QStringList helperArguments( const QStringList& args ); +QProcessEnvironment environment( const QString& extraPythonPath ); +std::expected parseOutput( int exitCode, const QByteArray& standardOutput, const QByteArray& standardError ); +QString errorMessage( const QJsonObject& error ); +} // namespace RimWorkflowHelperProcess diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowInstalledCollection.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowInstalledCollection.cpp new file mode 100644 index 0000000000..4357944941 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowInstalledCollection.cpp @@ -0,0 +1,29 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RimWorkflowInstalledCollection.h" + +CAF_PDM_SOURCE_INIT( RimWorkflowInstalledCollection, "WorkflowInstalledCollection" ); + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RimWorkflowInstalledCollection::RimWorkflowInstalledCollection() +{ + CAF_PDM_InitObject( "Installed", ":/Folder.png" ); + CAF_PDM_InitFieldNoDefault( &m_items, "Workflows", "" ); +} diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowInstalledCollection.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowInstalledCollection.h new file mode 100644 index 0000000000..59042481df --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowInstalledCollection.h @@ -0,0 +1,33 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026 Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// +#pragma once + +#include "RimWorkflow.h" + +#include "cafPdmObjectCollection.h" + +//================================================================================================== +/// Workflows registered by installed Python packages (read-only) +//================================================================================================== +class RimWorkflowInstalledCollection : public caf::PdmObjectCollection +{ + CAF_PDM_HEADER_INIT; + +public: + RimWorkflowInstalledCollection(); +}; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowIntBinding.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowIntBinding.cpp index 01c19c39f1..7b2fdb7157 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowIntBinding.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowIntBinding.cpp @@ -45,7 +45,7 @@ void RimWorkflowIntBinding::applySchema( const QJsonObject& fieldSchema ) //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -QString RimWorkflowIntBinding::toYamlValue() const +QJsonValue RimWorkflowIntBinding::toJsonValue() const { - return hasValue() ? QString::number( m_value() ) : "null"; + return hasValue() ? QJsonValue( m_value() ) : QJsonValue( QJsonValue::Null ); } diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowIntBinding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowIntBinding.h index 202316c95a..149837f18d 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowIntBinding.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowIntBinding.h @@ -27,8 +27,8 @@ class RimWorkflowIntBinding : public RimWorkflowFieldBinding public: RimWorkflowIntBinding(); - void applySchema( const QJsonObject& fieldSchema ) override; - QString toYamlValue() const override; + void applySchema( const QJsonObject& fieldSchema ) override; + QJsonValue toJsonValue() const override; caf::PdmFieldHandle* valueField() override { return &m_value; } diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.cpp index 5822f74217..de33d8e93b 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.cpp @@ -19,6 +19,7 @@ #include "RimWorkflowJob.h" #include "RimWorkflow.h" +#include "RimWorkflowHelperProcess.h" #include "RiaApplication.h" #include "RiaLogging.h" @@ -34,8 +35,7 @@ #include #include -#include -#include +#include #include #include @@ -87,7 +87,7 @@ void RimWorkflowJob::defineUiOrdering( QString uiConfigName, caf::PdmUiOrdering& for ( RimWorkflowTaskInput* task : m_taskInputs.childrenByType() ) { - if ( !task ) continue; + if ( !task || task->count() == 0 ) continue; caf::PdmUiGroup* group = uiOrdering.addNewGroup( task->taskName() ); for ( RimWorkflowFieldBinding* b : task->items() ) { @@ -244,17 +244,132 @@ void RimWorkflowJob::cancelJob() //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -QString RimWorkflowJob::writeInputYaml( const QString& path ) const +RimWorkflowTaskInput* RimWorkflowJob::taskInput( const QString& taskName ) const { - QString body; - for ( RimWorkflowTaskInput* t : m_taskInputs.childrenByType() ) + for ( RimWorkflowTaskInput* input : taskInputs() ) { - if ( t ) body += t->toTaskYamlBlock(); + if ( input->taskName() == taskName ) return input; + } + return nullptr; +} + +//-------------------------------------------------------------------------------------------------- +/// Make the task inputs follow the tasks of the workflow graph. Inputs of renamed tasks +/// (`renames` maps old to new names) keep their values; values of removed fields are kept aside +/// and restored if the field comes back. +//-------------------------------------------------------------------------------------------------- +void RimWorkflowJob::syncTaskInputs( const QJsonArray& graphTasks, const QMap& renames ) +{ + // Detached values are stored as "task/field/binding" + QMap detached; + for ( auto it = m_detachedValues.begin(); it != m_detachedValues.end(); ++it ) + { + const QString task = it.key().section( '/', 0, 0 ); + const QString rest = it.key().section( '/', 1 ); + detached.insert( renames.value( task, task ) + "/" + rest, it.value() ); } + std::vector existing = taskInputs(); + m_taskInputs.clearWithoutDelete(); + + auto takeExisting = [&existing, &renames]( const QString& name ) -> RimWorkflowTaskInput* + { + for ( auto it = existing.begin(); it != existing.end(); ++it ) + { + const QString currentName = renames.value( ( *it )->taskName(), ( *it )->taskName() ); + if ( currentName == name ) + { + RimWorkflowTaskInput* input = *it; + existing.erase( it ); + return input; + } + } + return nullptr; + }; + + for ( const QJsonValue& value : graphTasks ) + { + const QJsonObject task = value.toObject(); + const QString name = task.value( "name" ).toString(); + + RimWorkflowTaskInput* input = takeExisting( name ); + if ( !input ) input = new RimWorkflowTaskInput; + input->setTaskName( name ); + input->setTaskInfo( task.value( "task_id" ).toString(), task.value( "description" ).toString() ); + + QMap taskDetached; + const QString prefix = name + "/"; + for ( auto it = detached.begin(); it != detached.end(); ) + { + if ( it.key().startsWith( prefix ) ) + { + taskDetached.insert( it.key().mid( prefix.size() ), it.value() ); + it = detached.erase( it ); + } + else + { + ++it; + } + } + + input->syncFromSchema( task.value( "config_fields" ).toArray(), taskDetached ); + for ( auto it = taskDetached.begin(); it != taskDetached.end(); ++it ) + detached.insert( prefix + it.key(), it.value() ); + + m_taskInputs.push_back( input ); + } + + // Inputs of removed tasks are kept aside, in case the removal is undone + for ( RimWorkflowTaskInput* removed : existing ) + { + const QString name = renames.value( removed->taskName(), removed->taskName() ); + for ( RimWorkflowFieldBinding* binding : removed->items() ) + { + const QJsonValue value = binding->toJsonValue(); + if ( !value.isNull() ) + detached.insert( name + "/" + RimWorkflowTaskInput::detachedKey( binding->fieldName(), binding->classKeyword() ), value ); + } + delete removed; + } + + m_detachedValues = detached; + uiCapability()->updateConnectedEditors(); +} + +//-------------------------------------------------------------------------------------------------- +/// The values used to run the job, including references to ResInsight objects +//-------------------------------------------------------------------------------------------------- +QJsonObject RimWorkflowJob::inputValues() const +{ + QJsonObject values; + for ( RimWorkflowTaskInput* input : taskInputs() ) + { + if ( input->count() > 0 ) values.insert( input->taskName(), input->jsonValues() ); + } + return values; +} + +//-------------------------------------------------------------------------------------------------- +/// Values that can be saved to input.yaml +//-------------------------------------------------------------------------------------------------- +QJsonObject RimWorkflowJob::literalInputValues() const +{ + QJsonObject values; + for ( RimWorkflowTaskInput* input : taskInputs() ) + { + if ( input->count() > 0 ) values.insert( input->taskName(), input->literalValues() ); + } + return values; +} + +//-------------------------------------------------------------------------------------------------- +/// Write the input values as JSON, which is also valid YAML +//-------------------------------------------------------------------------------------------------- +QString RimWorkflowJob::writeInputYaml( const QString& path ) const +{ QFile out( path ); if ( !out.open( QIODevice::WriteOnly | QIODevice::Truncate ) ) return {}; - out.write( body.toUtf8() ); + out.write( QJsonDocument( inputValues() ).toJson( QJsonDocument::Indented ) ); out.close(); return path; } @@ -277,9 +392,6 @@ void RimWorkflowJob::runJob() return; } - const QString dir = workflow->workflowDirectory(); - if ( dir.isEmpty() ) return; - auto port = RiaApplication::instance()->activeGrpcPortNumber(); if ( !port.has_value() ) { @@ -294,6 +406,13 @@ void RimWorkflowJob::runJob() return; } + auto runSource = workflow->prepareRunSource(); + if ( !runSource ) + { + RiaLogging::warning( QString( "Cannot run workflow: %1" ).arg( runSource.error() ).toStdString() ); + return; + } + QDir tmp( QDir::tempPath() ); QString runDir = QString( "resinsight_workflow_%1" ).arg( QUuid::createUuid().toString( QUuid::WithoutBraces ) ); if ( !tmp.mkpath( runDir ) ) @@ -320,9 +439,10 @@ void RimWorkflowJob::runJob() 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 }; + const QStringList args = RimWorkflowHelperProcess::helperArguments( + runSource->baseArgs + QStringList{ "--input", inputPath, "--grpc-port", QString::number( port.value() ), "--run-id", m_runId } ); - QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + QProcessEnvironment env = RimWorkflowHelperProcess::environment( runSource->extraPythonPath ); const QString label = workflow->uiName() + " / " + m_name(); m_runner = new RiuWorkflowJobRunner( label, RiuMainWindow::instance() ); diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.h index 68ced071c0..f7a76b1eb1 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.h @@ -24,6 +24,9 @@ #include "cafPdmField.h" #include "cafPdmObject.h" +#include +#include +#include #include #include @@ -40,12 +43,16 @@ public: void setJobName( const QString& name ); std::vector taskInputs() const; + RimWorkflowTaskInput* taskInput( const QString& taskName ) const; void setTaskInputs( std::vector inputs ); + void syncTaskInputs( const QJsonArray& graphTasks, const QMap& renames = {} ); - QString writeInputYaml( const QString& path ) const; - void runJob(); - void cancelJob(); - bool isRunning() const; + QJsonObject inputValues() const; + QJsonObject literalInputValues() const; + QString writeInputYaml( const QString& path ) const; + void runJob(); + void cancelJob(); + bool isRunning() const; QMap taskStates() const; QMap taskErrors() const; @@ -64,6 +71,7 @@ private: caf::PdmField m_name; caf::PdmChildArrayField m_taskInputs; QPointer m_runner; + QMap m_detachedValues; QMap m_taskStates; QMap m_taskErrors; QString m_runStatus; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowPortCompatibility.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowPortCompatibility.cpp new file mode 100644 index 0000000000..a6e543e2af --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowPortCompatibility.cpp @@ -0,0 +1,436 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RimWorkflowPortCompatibility.h" + +#include "RimWorkflowDefinition.h" +#include "RimWorkflowSchemaTools.h" + +#include +#include +#include +#include + +namespace +{ +QString shortName( const QString& pythonType ) +{ + return pythonType.mid( pythonType.lastIndexOf( '.' ) + 1 ); +} + +QStringList pythonTypes( const QJsonObject& schema ) +{ + QStringList types; + for ( const char* key : { "x-taskmaestro-python-type", "x-ri-python-type" } ) + { + const QString type = schema.value( key ).toString(); + if ( !type.isEmpty() ) types.append( type ); + } + for ( const QJsonValue& base : schema.value( "x-ri-python-bases" ).toArray() ) + types.append( base.toString() ); + return types; +} + +QJsonArray enumValues( const QJsonObject& schema ) +{ + if ( schema.contains( "const" ) ) return QJsonArray{ schema.value( "const" ) }; + return schema.value( "enum" ).toArray(); +} + +bool isHiddenObjectValue( const QString& name, const QJsonObject& property, const QJsonObject& rootSchema ) +{ + // ObjectModel wraps its runtime object in an opaque `value` field that is not routable on its own + return name == "value" && RimWorkflowSchemaTools::isOpaque( property, rootSchema ); +} + +bool isModelCompatible( const QJsonObject& produced, const QJsonObject& expected ) +{ + const QString expectedType = expected.value( "x-ri-python-type" ).toString(); + if ( !expectedType.isEmpty() && produced.contains( "x-ri-python-type" ) ) return pythonTypes( produced ).contains( expectedType ); + + if ( expected.contains( "title" ) && produced.contains( "title" ) && expected.contains( "properties" ) ) + return expected.value( "title" ) == produced.value( "title" ); + return true; +} + +bool isScalarCompatible( const QJsonObject& produced, const QJsonObject& expected ) +{ + const QString producedType = produced.value( "type" ).toString(); + const QString expectedType = expected.value( "type" ).toString(); + + // bool is a subclass of int in Python, but int is not a subclass of float + if ( producedType != expectedType && !( producedType == "boolean" && expectedType == "integer" ) ) return false; + if ( produced.value( "format" ).toString() != expected.value( "format" ).toString() ) return false; + + const QJsonArray expectedEnum = enumValues( expected ); + if ( expectedEnum.isEmpty() ) return true; + const QJsonArray producedEnum = enumValues( produced ); + if ( producedEnum.isEmpty() ) return false; + for ( const QJsonValue& value : producedEnum ) + { + if ( !expectedEnum.contains( value ) ) return false; + } + return true; +} + +std::optional findPort( const std::vector& ports, const QString& name ) +{ + for ( const auto& port : ports ) + { + if ( port.name == name ) return port; + } + return std::nullopt; +} + +bool hasRequiredConfigurableField( const QJsonObject& inputSchema, const QStringList& covered ) +{ + const QJsonObject properties = inputSchema.value( "properties" ).toObject(); + for ( const QString& field : RimWorkflowSchemaTools::requiredFields( inputSchema ) ) + { + if ( covered.contains( field ) ) continue; + if ( RimWorkflowSchemaTools::isConfigurable( properties.value( field ).toObject(), inputSchema ) ) return true; + } + return false; +} + +std::expected + checkWholeToWhole( const QJsonObject& upstreamType, const QJsonObject& downstreamType, const QString& from, const QString& to ) +{ + const QJsonObject outputSchema = upstreamType.value( "output_schema" ).toObject(); + const QJsonObject inputSchema = downstreamType.value( "input_schema" ).toObject(); + if ( RimWorkflowPortCompatibility::isCompatible( outputSchema, outputSchema, inputSchema, inputSchema ) ) return {}; + + const QString mismatch = QString( "'%1' outputs %2, but '%3' expects %4" ) + .arg( from, RimWorkflowPortCompatibility::typeName( outputSchema, outputSchema ) ) + .arg( to, RimWorkflowPortCompatibility::typeName( inputSchema, inputSchema ) ); + + // taskmaestro merges the upstream fields with configured values when the task has config fields + const QStringList shared = RimWorkflowPortCompatibility::coveredByWholeOutput( upstreamType, downstreamType ); + if ( shared.isEmpty() || !hasRequiredConfigurableField( inputSchema, shared ) ) return std::unexpected( mismatch ); + + const QJsonObject outputProperties = outputSchema.value( "properties" ).toObject(); + const QJsonObject inputProperties = inputSchema.value( "properties" ).toObject(); + for ( const QString& field : shared ) + { + if ( !RimWorkflowPortCompatibility::isCompatible( outputProperties.value( field ).toObject(), + outputSchema, + inputProperties.value( field ).toObject(), + inputSchema ) ) + { + return std::unexpected( QString( "Type mismatch for field '%1': %2" ).arg( field, mismatch ) ); + } + } + return {}; +} +} // namespace + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RimWorkflowPort::isWhole() const +{ + return name.isEmpty(); +} + +//-------------------------------------------------------------------------------------------------- +/// The whole input model first, then its fields +//-------------------------------------------------------------------------------------------------- +std::vector RimWorkflowPortCompatibility::inputPorts( const QJsonObject& taskType ) +{ + const QJsonObject inputSchema = taskType.value( "input_schema" ).toObject(); + + std::vector ports; + ports.push_back( { .name = "", + .schema = inputSchema, + .rootSchema = inputSchema, + .description = inputSchema.value( "description" ).toString(), + .typeName = typeName( inputSchema, inputSchema ), + .required = true } ); + + const QStringList required = RimWorkflowSchemaTools::requiredFields( inputSchema ); + const QJsonObject properties = inputSchema.value( "properties" ).toObject(); + for ( const QString& name : RimWorkflowSchemaTools::propertyNames( inputSchema ) ) + { + const QJsonObject property = properties.value( name ).toObject(); + if ( isHiddenObjectValue( name, property, inputSchema ) ) continue; + ports.push_back( { .name = name, + .schema = property, + .rootSchema = inputSchema, + .description = property.value( "description" ).toString(), + .typeName = typeName( property, inputSchema ), + .required = required.contains( name ), + .configurable = RimWorkflowSchemaTools::isConfigurable( property, inputSchema ) } ); + } + return ports; +} + +//-------------------------------------------------------------------------------------------------- +/// The whole output model first, then its fields +//-------------------------------------------------------------------------------------------------- +std::vector RimWorkflowPortCompatibility::outputPorts( const QJsonObject& taskType ) +{ + const QJsonObject outputSchema = taskType.value( "output_schema" ).toObject(); + + std::vector ports; + ports.push_back( { .name = "", + .schema = outputSchema, + .rootSchema = outputSchema, + .description = outputSchema.value( "description" ).toString(), + .typeName = typeName( outputSchema, outputSchema ) } ); + + const QJsonObject properties = outputSchema.value( "properties" ).toObject(); + for ( const QString& name : RimWorkflowSchemaTools::propertyNames( outputSchema ) ) + { + const QJsonObject property = properties.value( name ).toObject(); + if ( isHiddenObjectValue( name, property, outputSchema ) ) continue; + ports.push_back( { .name = name, + .schema = property, + .rootSchema = outputSchema, + .description = property.value( "description" ).toString(), + .typeName = typeName( property, outputSchema ) } ); + } + return ports; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +std::optional RimWorkflowPortCompatibility::inputPort( const QJsonObject& taskType, const QString& name ) +{ + return findPort( inputPorts( taskType ), name ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +std::optional RimWorkflowPortCompatibility::outputPort( const QJsonObject& taskType, const QString& name ) +{ + return findPort( outputPorts( taskType ), name ); +} + +//-------------------------------------------------------------------------------------------------- +/// A schema without any type information accepts and produces anything +//-------------------------------------------------------------------------------------------------- +bool RimWorkflowPortCompatibility::isAny( const QJsonObject& schema, const QJsonObject& rootSchema ) +{ + const QJsonObject resolved = RimWorkflowSchemaTools::resolveReferences( schema, rootSchema ); + for ( const char* key : { "type", "anyOf", "oneOf", "properties", "enum", "const", "x-taskmaestro-opaque", "$ref" } ) + { + if ( resolved.contains( key ) ) return false; + } + return true; +} + +//-------------------------------------------------------------------------------------------------- +/// Whether every value of the produced schema is accepted by the expected schema. Models and +/// runtime objects compare their Python class and its bases, like `issubclass`. +//-------------------------------------------------------------------------------------------------- +bool RimWorkflowPortCompatibility::isCompatible( const QJsonObject& produced, + const QJsonObject& producedRoot, + const QJsonObject& expected, + const QJsonObject& expectedRoot ) +{ + if ( isAny( produced, producedRoot ) || isAny( expected, expectedRoot ) ) return true; + + const QJsonObject p = RimWorkflowSchemaTools::resolveReferences( produced, producedRoot ); + const QJsonObject e = RimWorkflowSchemaTools::resolveReferences( expected, expectedRoot ); + + // Every produced option must be accepted; an expected union needs one accepting option + for ( const char* unionKey : { "anyOf", "oneOf" } ) + { + if ( !p.value( unionKey ).isArray() ) continue; + for ( const QJsonValue& option : p.value( unionKey ).toArray() ) + { + if ( !isCompatible( option.toObject(), producedRoot, e, expectedRoot ) ) return false; + } + return true; + } + for ( const char* unionKey : { "anyOf", "oneOf" } ) + { + if ( !e.value( unionKey ).isArray() ) continue; + for ( const QJsonValue& option : e.value( unionKey ).toArray() ) + { + if ( isCompatible( p, producedRoot, option.toObject(), expectedRoot ) ) return true; + } + return false; + } + + const bool producedOpaque = p.value( "x-taskmaestro-opaque" ).toBool( false ); + if ( e.value( "x-taskmaestro-opaque" ).toBool( false ) ) + { + return producedOpaque && pythonTypes( p ).contains( e.value( "x-taskmaestro-python-type" ).toString() ); + } + if ( producedOpaque ) return false; + + const QString producedType = p.value( "type" ).toString(); + const QString expectedType = e.value( "type" ).toString(); + if ( producedType == "object" || expectedType == "object" ) + { + if ( producedType != expectedType ) return false; + if ( e.value( "additionalProperties" ).isObject() && !e.contains( "properties" ) ) + { + return isCompatible( p.value( "additionalProperties" ).toObject(), + producedRoot, + e.value( "additionalProperties" ).toObject(), + expectedRoot ); + } + return isModelCompatible( p, e ); + } + + if ( producedType == "array" || expectedType == "array" ) + { + if ( producedType != expectedType ) return false; + return isCompatible( p.value( "items" ).toObject(), producedRoot, e.value( "items" ).toObject(), expectedRoot ); + } + + return isScalarCompatible( p, e ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RimWorkflowPortCompatibility::isCompatible( const RimWorkflowPort& produced, const RimWorkflowPort& expected ) +{ + return isCompatible( produced.schema, produced.rootSchema, expected.schema, expected.rootSchema ); +} + +//-------------------------------------------------------------------------------------------------- +/// Short readable type name used in tooltips and error messages +//-------------------------------------------------------------------------------------------------- +QString RimWorkflowPortCompatibility::typeName( const QJsonObject& schema, const QJsonObject& rootSchema ) +{ + if ( isAny( schema, rootSchema ) ) return "Any"; + + const QJsonObject resolved = RimWorkflowSchemaTools::resolveReferences( schema, rootSchema ); + for ( const char* unionKey : { "anyOf", "oneOf" } ) + { + if ( !resolved.value( unionKey ).isArray() ) continue; + QStringList names; + for ( const QJsonValue& option : resolved.value( unionKey ).toArray() ) + names.append( typeName( option.toObject(), rootSchema ) ); + return names.join( " | " ); + } + + if ( resolved.value( "x-taskmaestro-opaque" ).toBool( false ) ) + return shortName( resolved.value( "x-taskmaestro-python-type" ).toString( "object" ) ); + if ( resolved.contains( "x-ri-python-type" ) ) return shortName( resolved.value( "x-ri-python-type" ).toString() ); + + const QString type = resolved.value( "type" ).toString(); + if ( type == "object" ) return resolved.value( "title" ).toString( "object" ); + if ( type == "array" ) return QString( "list[%1]" ).arg( typeName( resolved.value( "items" ).toObject(), rootSchema ) ); + + static const QMap pythonNames = { { "string", "str" }, + { "integer", "int" }, + { "number", "float" }, + { "boolean", "bool" }, + { "null", "None" } }; + const QString format = resolved.value( "format" ).toString(); + if ( format == "date" ) return "date"; + if ( format == "date-time" ) return "datetime"; + if ( format == "path" || format == "file-path" || format == "directory-path" ) return "Path"; + return pythonNames.value( type, type ); +} + +//-------------------------------------------------------------------------------------------------- +/// Input fields of the downstream task supplied when it takes the whole upstream output +//-------------------------------------------------------------------------------------------------- +QStringList RimWorkflowPortCompatibility::coveredByWholeOutput( const QJsonObject& upstreamTaskType, const QJsonObject& downstreamTaskType ) +{ + const QJsonObject outputSchema = upstreamTaskType.value( "output_schema" ).toObject(); + const QJsonObject inputSchema = downstreamTaskType.value( "input_schema" ).toObject(); + const QStringList inputFields = RimWorkflowSchemaTools::propertyNames( inputSchema ); + if ( isCompatible( outputSchema, outputSchema, inputSchema, inputSchema ) ) return inputFields; + + const QStringList outputFields = RimWorkflowSchemaTools::propertyNames( outputSchema ); + QStringList covered; + for ( const QString& field : inputFields ) + { + if ( outputFields.contains( field ) ) covered.append( field ); + } + return covered; +} + +//-------------------------------------------------------------------------------------------------- +/// Whether adding the connection from -> to closes a cycle +//-------------------------------------------------------------------------------------------------- +bool RimWorkflowPortCompatibility::wouldCreateCycle( const RimWorkflowDefinition& definition, const QString& from, const QString& to ) +{ + if ( from == to ) return true; + + QStringList pending{ to }; + QSet visited; + while ( !pending.isEmpty() ) + { + const QString current = pending.takeLast(); + if ( current == from ) return true; + if ( visited.contains( current ) ) continue; + visited.insert( current ); + for ( const auto& edge : definition.edges ) + { + if ( edge.from == current ) pending.append( edge.to ); + } + } + return false; +} + +//-------------------------------------------------------------------------------------------------- +/// Checks a new connection. An existing connection to the same input is replaced and ignored here. +//-------------------------------------------------------------------------------------------------- +std::expected RimWorkflowPortCompatibility::canConnect( const RimWorkflowDefinition& definition, + const QString& from, + const QString& output, + const QString& to, + const QString& input ) +{ + if ( from == to ) return std::unexpected( "A task cannot be connected to itself" ); + if ( !definition.findNode( from ) ) return std::unexpected( QString( "Unknown task '%1'" ).arg( from ) ); + if ( !definition.findNode( to ) ) return std::unexpected( QString( "Unknown task '%1'" ).arg( to ) ); + + const QJsonObject upstreamType = definition.taskTypeForNode( from ); + const QJsonObject downstreamType = definition.taskTypeForNode( to ); + if ( upstreamType.isEmpty() ) return std::unexpected( QString( "The task type of '%1' is not installed" ).arg( from ) ); + if ( downstreamType.isEmpty() ) return std::unexpected( QString( "The task type of '%1' is not installed" ).arg( to ) ); + + const auto producedPort = outputPort( upstreamType, output ); + if ( !producedPort ) return std::unexpected( QString( "'%1' has no output '%2'" ).arg( from, output ) ); + const auto expectedPort = inputPort( downstreamType, input ); + if ( !expectedPort ) return std::unexpected( QString( "'%1' has no input '%2'" ).arg( to, input ) ); + + // Edges into the same input are replaced by the new connection + RimWorkflowDefinition remaining = definition; + std::erase_if( remaining.edges, [&]( const RimWorkflowDefinitionEdge& edge ) { return edge.to == to && edge.input == input; } ); + if ( wouldCreateCycle( remaining, from, to ) ) return std::unexpected( "The connection would create a cycle" ); + + for ( const auto& edge : remaining.incomingEdges( to ) ) + { + if ( input.isEmpty() && !edge.isWholeInput() ) + return std::unexpected( QString( "'%1' already has individual inputs connected and cannot also take a whole input" ).arg( to ) ); + if ( !input.isEmpty() && edge.isWholeInput() ) + return std::unexpected( QString( "'%1' takes its whole input from '%2'; disconnect it first" ).arg( to, edge.from ) ); + } + + if ( input.isEmpty() && output.isEmpty() ) return checkWholeToWhole( upstreamType, downstreamType, from, to ); + + if ( !isCompatible( *producedPort, *expectedPort ) ) + { + const QString source = output.isEmpty() ? from : from + "." + output; + const QString target = input.isEmpty() ? to : to + "." + input; + return std::unexpected( QString( "%1 is %2, but %3 expects %4" ).arg( source, producedPort->typeName, target, expectedPort->typeName ) ); + } + return {}; +} diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowPortCompatibility.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowPortCompatibility.h new file mode 100644 index 0000000000..21e82c9874 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowPortCompatibility.h @@ -0,0 +1,67 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 + +#include +#include +#include + +struct RimWorkflowDefinition; + +//================================================================================================== +/// A connectable input or output of a task. An empty name is the whole input or output model. +//================================================================================================== +struct RimWorkflowPort +{ + QString name{}; + QJsonObject schema{}; + QJsonObject rootSchema{}; + QString description{}; + QString typeName{}; + bool required = true; + bool configurable = false; + + bool isWhole() const; +}; + +//================================================================================================== +/// Static type checks for workflow connections. These mirror taskmaestro's `_is_type_compatible` +/// on the JSON schemas; `taskmaestro workflow describe` has the final say. +//================================================================================================== +namespace RimWorkflowPortCompatibility +{ +std::vector inputPorts( const QJsonObject& taskType ); +std::vector outputPorts( const QJsonObject& taskType ); +std::optional inputPort( const QJsonObject& taskType, const QString& name ); +std::optional outputPort( const QJsonObject& taskType, const QString& name ); + +bool isCompatible( const QJsonObject& produced, const QJsonObject& producedRoot, const QJsonObject& expected, const QJsonObject& expectedRoot ); +bool isCompatible( const RimWorkflowPort& produced, const RimWorkflowPort& expected ); +bool isAny( const QJsonObject& schema, const QJsonObject& rootSchema ); +QString typeName( const QJsonObject& schema, const QJsonObject& rootSchema ); + +QStringList coveredByWholeOutput( const QJsonObject& upstreamTaskType, const QJsonObject& downstreamTaskType ); +bool wouldCreateCycle( const RimWorkflowDefinition& definition, const QString& from, const QString& to ); + +std::expected + canConnect( const RimWorkflowDefinition& definition, const QString& from, const QString& output, const QString& to, const QString& input ); +} // namespace RimWorkflowPortCompatibility diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowSchemaTools.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowSchemaTools.cpp new file mode 100644 index 0000000000..8a1c908a8c --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowSchemaTools.cpp @@ -0,0 +1,265 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RimWorkflowSchemaTools.h" + +#include +#include +#include + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QJsonObject RimWorkflowSchemaTools::resolveRef( const QString& ref, const QJsonObject& rootSchema ) +{ + const QString prefix = "#/$defs/"; + if ( !ref.startsWith( prefix ) ) return {}; + return rootSchema.value( "$defs" ).toObject().value( ref.mid( prefix.size() ) ).toObject(); +} + +//-------------------------------------------------------------------------------------------------- +/// Follow $ref and unwrap single-item allOf, keeping the sibling keywords of a $ref (description, default) +//-------------------------------------------------------------------------------------------------- +QJsonObject RimWorkflowSchemaTools::resolveReferences( QJsonObject schema, const QJsonObject& rootSchema ) +{ + constexpr int maxDepth = 16; + for ( int depth = 0; depth < maxDepth; ++depth ) + { + if ( schema.contains( "$ref" ) ) + { + QJsonObject target = resolveRef( schema.value( "$ref" ).toString(), rootSchema ); + schema.remove( "$ref" ); + for ( auto it = schema.begin(); it != schema.end(); ++it ) + { + if ( !target.contains( it.key() ) ) target.insert( it.key(), it.value() ); + } + schema = target; + continue; + } + + const QJsonArray allOf = schema.value( "allOf" ).toArray(); + if ( allOf.size() == 1 ) + { + schema = allOf.first().toObject(); + continue; + } + break; + } + return schema; +} + +//-------------------------------------------------------------------------------------------------- +/// Follow $ref and unwrap single-item allOf and Optional (anyOf with null) to the underlying type +//-------------------------------------------------------------------------------------------------- +QJsonObject RimWorkflowSchemaTools::underlyingSchema( QJsonObject schema, const QJsonObject& rootSchema ) +{ + constexpr int maxDepth = 16; + for ( int depth = 0; depth < maxDepth; ++depth ) + { + if ( schema.contains( "$ref" ) ) + { + schema = resolveRef( schema.value( "$ref" ).toString(), rootSchema ); + continue; + } + + const QJsonArray allOf = schema.value( "allOf" ).toArray(); + if ( allOf.size() == 1 ) + { + schema = allOf.first().toObject(); + continue; + } + + if ( schema.value( "anyOf" ).isArray() ) + { + QJsonArray nonNull; + for ( const QJsonValue& arm : schema.value( "anyOf" ).toArray() ) + { + if ( arm.toObject().value( "type" ).toString() != "null" ) nonNull.append( arm ); + } + if ( nonNull.size() == 1 ) + { + schema = nonNull.first().toObject(); + continue; + } + } + break; + } + return schema; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RimWorkflowSchemaTools::isOpaque( const QJsonObject& schema, const QJsonObject& rootSchema ) +{ + return underlyingSchema( schema, rootSchema ).value( "x-taskmaestro-opaque" ).toBool( false ); +} + +//-------------------------------------------------------------------------------------------------- +/// Python type of a runtime-only object, either bare or wrapped in an ObjectModel `value` field +//-------------------------------------------------------------------------------------------------- +QString RimWorkflowSchemaTools::objectPythonType( const QJsonObject& typeSchema, const QJsonObject& rootSchema ) +{ + if ( typeSchema.contains( "x-taskmaestro-python-type" ) ) return typeSchema.value( "x-taskmaestro-python-type" ).toString(); + + const QJsonObject valueSchema = typeSchema.value( "properties" ).toObject().value( "value" ).toObject(); + if ( valueSchema.isEmpty() ) return {}; + return underlyingSchema( valueSchema, rootSchema ).value( "x-taskmaestro-python-type" ).toString(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QString RimWorkflowSchemaTools::jsonType( const QJsonObject& typeSchema ) +{ + const QString type = typeSchema.value( "type" ).toString(); + if ( type == "boolean" || type == "integer" || type == "number" || type == "array" ) return type; + return "string"; +} + +//-------------------------------------------------------------------------------------------------- +/// taskmaestro_resinsight.models.Vec3 is a plain pydantic model (x, y, z numbers), so it has no +/// x-taskmaestro-python-type tag; recognize it structurally instead. +//-------------------------------------------------------------------------------------------------- +bool RimWorkflowSchemaTools::isVec3Schema( const QJsonObject& typeSchema ) +{ + if ( typeSchema.value( "type" ).toString() != "object" ) return false; + + const QJsonObject properties = typeSchema.value( "properties" ).toObject(); + if ( properties.size() != 3 ) return false; + + for ( const QString& component : QStringList{ "x", "y", "z" } ) + { + if ( properties.value( component ).toObject().value( "type" ).toString() != "number" ) return false; + } + return true; +} + +//-------------------------------------------------------------------------------------------------- +/// Map a Python class path such as `rips.generated.generated_classes.WellPath` to the ResInsight +/// object type used to select the field binding. Returns an empty string for unsupported types. +//-------------------------------------------------------------------------------------------------- +QString RimWorkflowSchemaTools::resinsightTypeFromPythonType( const QString& pythonType ) +{ + const int separator = pythonType.lastIndexOf( '.' ); + if ( separator < 0 ) return {}; + + const QString module = pythonType.left( separator ); + if ( module != "rips" && !module.startsWith( "rips." ) ) return {}; + + static const QMap typeMap = { { "Case", "EclipseCase" }, + { "Reservoir", "EclipseCase" }, + { "EclipseCase", "EclipseCase" }, + { "WellPath", "WellPath" }, + { "View", "View" }, + { "EclipseView", "View" } }; + return typeMap.value( pythonType.mid( separator + 1 ) ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QStringList RimWorkflowSchemaTools::requiredFields( const QJsonObject& modelSchema ) +{ + QStringList fields; + for ( const QJsonValue& value : modelSchema.value( "required" ).toArray() ) + fields.append( value.toString() ); + return fields; +} + +//-------------------------------------------------------------------------------------------------- +/// Property names in schema order +//-------------------------------------------------------------------------------------------------- +QStringList RimWorkflowSchemaTools::propertyNames( const QJsonObject& modelSchema ) +{ + // QJsonObject sorts its keys; the `required` list and the original document keep model order + QStringList names; + const QJsonObject properties = modelSchema.value( "properties" ).toObject(); + for ( const QString& name : requiredFields( modelSchema ) ) + { + if ( properties.contains( name ) && !names.contains( name ) ) names.append( name ); + } + for ( const QString& name : properties.keys() ) + { + if ( !names.contains( name ) ) names.append( name ); + } + return names; +} + +//-------------------------------------------------------------------------------------------------- +/// Whether a value for the input property can be entered in ResInsight (a literal or an object reference) +//-------------------------------------------------------------------------------------------------- +bool RimWorkflowSchemaTools::isConfigurable( const QJsonObject& property, const QJsonObject& rootSchema ) +{ + const QJsonObject typeSchema = underlyingSchema( property, rootSchema ); + const QString pythonType = objectPythonType( typeSchema, rootSchema ); + if ( !pythonType.isEmpty() ) + { + // An object model with more required fields than `value` cannot be given as a single object + QStringList required = requiredFields( typeSchema ); + required.removeAll( "value" ); + return required.isEmpty() && !resinsightTypeFromPythonType( pythonType ).isEmpty(); + } + if ( typeSchema.value( "x-taskmaestro-opaque" ).toBool( false ) ) return false; + if ( isVec3Schema( typeSchema ) ) return true; + return typeSchema.value( "type" ).toString() != "object"; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QJsonObject RimWorkflowSchemaTools::configFieldSchema( const QString& fieldName, + const QJsonObject& inputSchema, + const QStringList& requiredFields, + const QJsonObject& configValues ) +{ + QJsonObject entry{ { "name", fieldName } }; + + const QJsonObject properties = inputSchema.value( "properties" ).toObject(); + if ( !properties.contains( fieldName ) ) + { + entry["type"] = "string"; + entry["required"] = true; + entry["error"] = "field not found in input model"; + return entry; + } + + const QJsonObject property = properties.value( fieldName ).toObject(); + const QJsonObject typeSchema = underlyingSchema( property, inputSchema ); + QString resinsightType = resinsightTypeFromPythonType( objectPythonType( typeSchema, inputSchema ) ); + if ( resinsightType.isEmpty() && isVec3Schema( typeSchema ) ) resinsightType = "Vec3"; + + entry["type"] = resinsightType.isEmpty() ? jsonType( typeSchema ) : "object"; + entry["required"] = requiredFields.contains( fieldName ); + + const QString description = property.value( "description" ).toString(); + if ( !description.isEmpty() ) entry["description"] = description; + + // Prefer the value already in input.yaml; fall back to the model default + if ( configValues.contains( fieldName ) ) + entry["default"] = configValues.value( fieldName ); + else if ( property.contains( "default" ) ) + entry["default"] = property.value( "default" ); + + const QString format = typeSchema.value( "format" ).toString(); + if ( !format.isEmpty() ) entry["format"] = format; + + if ( !resinsightType.isEmpty() ) entry["resinsight_type"] = resinsightType; + + return entry; +} diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowSchemaTools.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowSchemaTools.h new file mode 100644 index 0000000000..f1bb24357f --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowSchemaTools.h @@ -0,0 +1,46 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include +#include + +//================================================================================================== +/// Helpers for the JSON schemas that taskmaestro generates for task input and output models +//================================================================================================== +namespace RimWorkflowSchemaTools +{ +QJsonObject resolveRef( const QString& ref, const QJsonObject& rootSchema ); +QJsonObject resolveReferences( QJsonObject schema, const QJsonObject& rootSchema ); +QJsonObject underlyingSchema( QJsonObject schema, const QJsonObject& rootSchema ); +bool isOpaque( const QJsonObject& schema, const QJsonObject& rootSchema ); +QString objectPythonType( const QJsonObject& typeSchema, const QJsonObject& rootSchema ); +QString jsonType( const QJsonObject& typeSchema ); +bool isVec3Schema( const QJsonObject& typeSchema ); +QString resinsightTypeFromPythonType( const QString& pythonType ); +QStringList requiredFields( const QJsonObject& modelSchema ); +QStringList propertyNames( const QJsonObject& modelSchema ); +bool isConfigurable( const QJsonObject& property, const QJsonObject& rootSchema ); + +QJsonObject configFieldSchema( const QString& fieldName, + const QJsonObject& inputSchema, + const QStringList& requiredFields, + const QJsonObject& configValues ); +} // namespace RimWorkflowSchemaTools diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowStringBinding.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowStringBinding.cpp index fe1d742291..9cc076a303 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowStringBinding.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowStringBinding.cpp @@ -45,7 +45,7 @@ void RimWorkflowStringBinding::applySchema( const QJsonObject& fieldSchema ) //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -QString RimWorkflowStringBinding::toYamlValue() const +QJsonValue RimWorkflowStringBinding::toJsonValue() const { - return hasValue() ? yamlQuotedScalar( m_value() ) : "null"; + return hasValue() ? QJsonValue( m_value() ) : QJsonValue( QJsonValue::Null ); } diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowStringBinding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowStringBinding.h index aaabd427aa..14fc1bda80 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowStringBinding.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowStringBinding.h @@ -27,8 +27,8 @@ class RimWorkflowStringBinding : public RimWorkflowFieldBinding public: RimWorkflowStringBinding(); - void applySchema( const QJsonObject& fieldSchema ) override; - QString toYamlValue() const override; + void applySchema( const QJsonObject& fieldSchema ) override; + QJsonValue toJsonValue() const override; caf::PdmFieldHandle* valueField() override { return &m_value; } diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskCatalog.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskCatalog.cpp new file mode 100644 index 0000000000..f2dbd3552f --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskCatalog.cpp @@ -0,0 +1,192 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RimWorkflowTaskCatalog.h" + +#include "RimWorkflowHelperProcess.h" + +#include "RiaLogging.h" + +#include + +namespace +{ +constexpr int catalogTimeoutMs = 120000; + +std::optional& cachedCatalog() +{ + static std::optional catalog; + return catalog; +} + +RimWorkflowTaskCatalog loadCatalog() +{ + auto result = RimWorkflowHelperProcess::runSync( { "catalog" }, {}, catalogTimeoutMs ); + if ( !result ) + { + RiaLogging::warning( QString( "Could not load the workflow task catalog: %1" ).arg( result.error() ).toStdString() ); + return RimWorkflowTaskCatalog::failed( result.error() ); + } + + auto catalog = RimWorkflowTaskCatalog::fromJson( result.value() ); + for ( const QString& error : catalog.pluginErrors() ) + RiaLogging::warning( QString( "Workflow plugin: %1" ).arg( error ).toStdString() ); + return catalog; +} + +QString pluginErrorText( const QJsonValue& error ) +{ + if ( error.isString() ) return error.toString(); + const QJsonObject object = error.toObject(); + const QString source = object.value( "id" ).toString( object.value( "entry_point" ).toString() ); + const QString text = object.value( "message" ).toString(); + return source.isEmpty() ? text : source + ": " + text; +} +} // namespace + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RimWorkflowTaskCatalog RimWorkflowTaskCatalog::fromJson( const QJsonObject& catalog ) +{ + RimWorkflowTaskCatalog result; + result.m_valid = true; + result.m_taskmaestroVersion = catalog.value( "taskmaestro_version" ).toString(); + result.m_workflows = catalog.value( "workflows" ).toArray(); + for ( const QJsonValue& error : catalog.value( "errors" ).toArray() ) + result.m_pluginErrors.append( pluginErrorText( error ) ); + + for ( const QJsonValue& value : catalog.value( "tasks" ).toArray() ) + { + const QJsonObject task = value.toObject(); + const QString id = task.value( "id" ).toString(); + if ( id.isEmpty() || result.m_taskTypes.contains( id ) ) continue; + result.m_taskTypes.insert( id, task ); + result.m_taskOrder.append( id ); + } + return result; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RimWorkflowTaskCatalog RimWorkflowTaskCatalog::failed( const QString& errorMessage ) +{ + RimWorkflowTaskCatalog result; + result.m_loadError = errorMessage; + return result; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +const RimWorkflowTaskCatalog& RimWorkflowTaskCatalog::instance() +{ + auto& catalog = cachedCatalog(); + if ( !catalog ) catalog = loadCatalog(); + return *catalog; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RimWorkflowTaskCatalog::invalidate() +{ + cachedCatalog().reset(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RimWorkflowTaskCatalog::setInstance( const RimWorkflowTaskCatalog& catalog ) +{ + cachedCatalog() = catalog; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RimWorkflowTaskCatalog::isValid() const +{ + return m_valid; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QString RimWorkflowTaskCatalog::loadError() const +{ + return m_loadError; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QStringList RimWorkflowTaskCatalog::pluginErrors() const +{ + return m_pluginErrors; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QString RimWorkflowTaskCatalog::taskmaestroVersion() const +{ + return m_taskmaestroVersion; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QJsonObject RimWorkflowTaskCatalog::taskType( const QString& taskId ) const +{ + return m_taskTypes.value( taskId ).toObject(); +} + +//-------------------------------------------------------------------------------------------------- +/// Task descriptors in catalog order +//-------------------------------------------------------------------------------------------------- +std::vector RimWorkflowTaskCatalog::tasks() const +{ + std::vector result; + result.reserve( m_taskOrder.size() ); + for ( const QString& id : m_taskOrder ) + result.push_back( m_taskTypes.value( id ).toObject() ); + return result; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QJsonArray RimWorkflowTaskCatalog::registeredWorkflows() const +{ + return m_workflows; +} + +//-------------------------------------------------------------------------------------------------- +/// Add task types (from a workflow definition) that are not in the catalog +//-------------------------------------------------------------------------------------------------- +void RimWorkflowTaskCatalog::merge( const QJsonObject& taskTypes ) +{ + for ( auto it = taskTypes.begin(); it != taskTypes.end(); ++it ) + { + if ( m_taskTypes.contains( it.key() ) || !it.value().isObject() ) continue; + m_taskTypes.insert( it.key(), it.value() ); + m_taskOrder.append( it.key() ); + } +} diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskCatalog.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskCatalog.h new file mode 100644 index 0000000000..2ffaca8bfd --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskCatalog.h @@ -0,0 +1,61 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 +#include +#include + +#include + +//================================================================================================== +/// Task types and registered workflows published by installed packages (`taskmaestro_helper catalog`). +/// The process-wide instance is loaded on first use and cached until `invalidate()` (Rescan). +//================================================================================================== +class RimWorkflowTaskCatalog +{ +public: + static RimWorkflowTaskCatalog fromJson( const QJsonObject& catalog ); + static RimWorkflowTaskCatalog failed( const QString& errorMessage ); + + static const RimWorkflowTaskCatalog& instance(); + static void invalidate(); + static void setInstance( const RimWorkflowTaskCatalog& catalog ); + + bool isValid() const; + QString loadError() const; + QStringList pluginErrors() const; + QString taskmaestroVersion() const; + + QJsonObject taskType( const QString& taskId ) const; + std::vector tasks() const; + QJsonArray registeredWorkflows() const; + + void merge( const QJsonObject& taskTypes ); + +private: + bool m_valid = false; + QString m_loadError; + QStringList m_pluginErrors; + QString m_taskmaestroVersion; + QJsonObject m_taskTypes; + QStringList m_taskOrder; + QJsonArray m_workflows; +}; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.cpp index 9935bbfae8..2e31f5a1e5 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.cpp @@ -15,7 +15,6 @@ // for more details. // ///////////////////////////////////////////////////////////////////////////////// - #include "RimWorkflowTaskInput.h" #include "RimWorkflowArrayBinding.h" @@ -31,39 +30,13 @@ #include "RimWorkflowWellPathBinding.h" #include "cafPdmUiOrdering.h" +#include "cafPdmUiTextEditor.h" #include "cafPdmUiTreeOrdering.h" #include -#include CAF_PDM_SOURCE_INIT( RimWorkflowTaskInput, "WorkflowTaskInput" ); -namespace -{ -//-------------------------------------------------------------------------------------------------- -/// -//-------------------------------------------------------------------------------------------------- -RimWorkflowFieldBinding* createBinding( const QJsonObject& schema ) -{ - const QString resinsightType = schema.value( "resinsight_type" ).toString(); - if ( resinsightType == "EclipseCase" ) return new RimWorkflowCaseBinding; - if ( resinsightType == "WellPath" ) return new RimWorkflowWellPathBinding; - if ( resinsightType == "View" ) return new RimWorkflowViewBinding; - if ( resinsightType == "Vec3" ) return new RimWorkflowVec3Binding; - - const QString type = schema.value( "type" ).toString( "string" ); - const QString format = schema.value( "format" ).toString(); - if ( type == "string" && format == "date" ) return new RimWorkflowDateBinding; - if ( type == "string" && ( format == "path" || format == "file-path" || format == "directory-path" ) ) - return new RimWorkflowFilePathBinding; - if ( type == "boolean" ) return new RimWorkflowBoolBinding; - if ( type == "integer" ) return new RimWorkflowIntBinding; - if ( type == "number" ) return new RimWorkflowFloatBinding; - if ( type == "array" ) return new RimWorkflowArrayBinding; - return new RimWorkflowStringBinding; -} -} // namespace - //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- @@ -74,6 +47,13 @@ RimWorkflowTaskInput::RimWorkflowTaskInput() CAF_PDM_InitFieldNoDefault( &m_taskName, "TaskName", "Task" ); m_taskName.uiCapability()->setUiReadOnly( true ); + + CAF_PDM_InitFieldNoDefault( &m_taskType, "TaskType", "Task Type" ); + m_taskType.uiCapability()->setUiReadOnly( true ); + + CAF_PDM_InitFieldNoDefault( &m_taskDescription, "TaskDescription", "Description" ); + m_taskDescription.uiCapability()->setUiReadOnly( true ); + m_taskDescription.uiCapability()->setUiEditorTypeName( caf::PdmUiTextEditor::uiEditorTypeName() ); } //-------------------------------------------------------------------------------------------------- @@ -93,19 +73,164 @@ void RimWorkflowTaskInput::setTaskName( const QString& name ) setUiName( name ); } +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QString RimWorkflowTaskInput::taskType() const +{ + return m_taskType(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RimWorkflowTaskInput::setTaskInfo( const QString& taskType, const QString& description ) +{ + m_taskType = taskType; + m_taskDescription = description; +} + +//-------------------------------------------------------------------------------------------------- +/// The class keyword of the binding used for a config field schema +//-------------------------------------------------------------------------------------------------- +QString RimWorkflowTaskInput::bindingClassKeyword( const QJsonObject& fieldSchema ) +{ + const QString resinsightType = fieldSchema.value( "resinsight_type" ).toString(); + if ( resinsightType == "EclipseCase" ) return RimWorkflowCaseBinding::classKeywordStatic(); + if ( resinsightType == "WellPath" ) return RimWorkflowWellPathBinding::classKeywordStatic(); + if ( resinsightType == "View" ) return RimWorkflowViewBinding::classKeywordStatic(); + if ( resinsightType == "Vec3" ) return RimWorkflowVec3Binding::classKeywordStatic(); + + const QString type = fieldSchema.value( "type" ).toString( "string" ); + const QString format = fieldSchema.value( "format" ).toString(); + if ( type == "string" && format == "date" ) return RimWorkflowDateBinding::classKeywordStatic(); + if ( type == "string" && ( format == "path" || format == "file-path" || format == "directory-path" ) ) + return RimWorkflowFilePathBinding::classKeywordStatic(); + if ( type == "boolean" ) return RimWorkflowBoolBinding::classKeywordStatic(); + if ( type == "integer" ) return RimWorkflowIntBinding::classKeywordStatic(); + if ( type == "number" ) return RimWorkflowFloatBinding::classKeywordStatic(); + if ( type == "array" ) return RimWorkflowArrayBinding::classKeywordStatic(); + return RimWorkflowStringBinding::classKeywordStatic(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RimWorkflowFieldBinding* RimWorkflowTaskInput::createBinding( const QJsonObject& fieldSchema ) +{ + const QString keyword = bindingClassKeyword( fieldSchema ); + if ( keyword == RimWorkflowCaseBinding::classKeywordStatic() ) return new RimWorkflowCaseBinding; + if ( keyword == RimWorkflowWellPathBinding::classKeywordStatic() ) return new RimWorkflowWellPathBinding; + if ( keyword == RimWorkflowViewBinding::classKeywordStatic() ) return new RimWorkflowViewBinding; + if ( keyword == RimWorkflowVec3Binding::classKeywordStatic() ) return new RimWorkflowVec3Binding; + if ( keyword == RimWorkflowDateBinding::classKeywordStatic() ) return new RimWorkflowDateBinding; + if ( keyword == RimWorkflowFilePathBinding::classKeywordStatic() ) return new RimWorkflowFilePathBinding; + if ( keyword == RimWorkflowBoolBinding::classKeywordStatic() ) return new RimWorkflowBoolBinding; + if ( keyword == RimWorkflowIntBinding::classKeywordStatic() ) return new RimWorkflowIntBinding; + if ( keyword == RimWorkflowFloatBinding::classKeywordStatic() ) return new RimWorkflowFloatBinding; + if ( keyword == RimWorkflowArrayBinding::classKeywordStatic() ) return new RimWorkflowArrayBinding; + return new RimWorkflowStringBinding; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QString RimWorkflowTaskInput::detachedKey( const QString& fieldName, const QString& classKeyword ) +{ + return fieldName + "/" + classKeyword; +} + //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- void RimWorkflowTaskInput::buildFromSchema( const QJsonArray& configFields ) { + QMap noDetachedValues; deleteAllItems(); - for ( const QJsonValue& v : configFields ) + syncFromSchema( configFields, noDetachedValues ); +} + +//-------------------------------------------------------------------------------------------------- +/// Update the bindings to a new list of config fields. A binding is kept with its value when the +/// field still has the same kind of binding. Values of removed bindings are moved to +/// `detachedValues`, and restored from there when the field comes back. +//-------------------------------------------------------------------------------------------------- +void RimWorkflowTaskInput::syncFromSchema( const QJsonArray& configFields, QMap& detachedValues ) +{ + std::vector existing = items(); + m_items.clearWithoutDelete(); + + for ( const QJsonValue& value : configFields ) { - const QJsonObject schema = v.toObject(); - auto* binding = createBinding( schema ); + QJsonObject schema = value.toObject(); + const QString field = schema.value( "name" ).toString(); + const QString keyword = bindingClassKeyword( schema ); + + RimWorkflowFieldBinding* binding = nullptr; + for ( auto it = existing.begin(); it != existing.end(); ++it ) + { + if ( *it && ( *it )->fieldName() == field && ( *it )->classKeyword() == keyword ) + { + binding = *it; + existing.erase( it ); + break; + } + } + + if ( binding ) + { + const QJsonValue current = binding->toJsonValue(); + if ( !current.isNull() ) schema["default"] = current; + } + else + { + binding = createBinding( schema ); + const QString key = detachedKey( field, keyword ); + if ( detachedValues.contains( key ) ) schema["default"] = detachedValues.take( key ); + } binding->applySchema( schema ); addItem( binding ); } + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- + for ( RimWorkflowFieldBinding* removed : existing ) + { + if ( !removed ) continue; + const QJsonValue value = removed->toJsonValue(); + if ( !value.isNull() ) detachedValues.insert( detachedKey( removed->fieldName(), removed->classKeyword() ), value ); + delete removed; + } +} + +//-------------------------------------------------------------------------------------------------- +/// All values that are set, including references to ResInsight objects +//-------------------------------------------------------------------------------------------------- +QJsonObject RimWorkflowTaskInput::jsonValues() const +{ + QJsonObject values; + for ( const RimWorkflowFieldBinding* binding : items() ) + { + const QJsonValue value = binding->toJsonValue(); + if ( !value.isNull() ) values.insert( binding->fieldName(), value ); + } + return values; +} + +//-------------------------------------------------------------------------------------------------- +/// Values that can be stored in input.yaml, without references to objects in the project +//-------------------------------------------------------------------------------------------------- +QJsonObject RimWorkflowTaskInput::literalValues() const +{ + QJsonObject values; + for ( const RimWorkflowFieldBinding* binding : items() ) + { + if ( binding->isObjectReference() ) continue; + const QJsonValue value = binding->toJsonValue(); + if ( !value.isNull() ) values.insert( binding->fieldName(), value ); + } + return values; } //-------------------------------------------------------------------------------------------------- @@ -113,9 +238,12 @@ void RimWorkflowTaskInput::buildFromSchema( const QJsonArray& configFields ) //-------------------------------------------------------------------------------------------------- void RimWorkflowTaskInput::defineUiOrdering( QString uiConfigName, caf::PdmUiOrdering& uiOrdering ) { - for ( RimWorkflowFieldBinding* b : items() ) + if ( !m_taskType().isEmpty() ) uiOrdering.add( &m_taskType ); + if ( !m_taskDescription().isEmpty() ) uiOrdering.add( &m_taskDescription ); + + for ( RimWorkflowFieldBinding* binding : items() ) { - if ( b && b->valueField() ) uiOrdering.add( b->valueField() ); + if ( binding && binding->valueField() ) uiOrdering.add( binding->valueField() ); } uiOrdering.skipRemainingFields( true ); } @@ -127,21 +255,3 @@ void RimWorkflowTaskInput::defineUiTreeOrdering( caf::PdmUiTreeOrdering& uiTreeO { uiTreeOrdering.skipRemainingChildren( true ); } - -//-------------------------------------------------------------------------------------------------- -/// -//-------------------------------------------------------------------------------------------------- -QString RimWorkflowTaskInput::toTaskYamlBlock() const -{ - QString out = m_taskName() + ":\n"; - if ( count() == 0 ) - { - out += " {}\n"; - return out; - } - for ( const RimWorkflowFieldBinding* b : items() ) - { - out += QString( " %1: %2\n" ).arg( b->fieldName(), b->toYamlValue() ); - } - return out; -} diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.h index 8a15fea8a1..9bb1163135 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.h @@ -15,15 +15,22 @@ // for more details. // ///////////////////////////////////////////////////////////////////////////////// - #pragma once #include "RimWorkflowFieldBinding.h" #include "cafPdmObjectCollection.h" +#include +#include +#include + class QJsonArray; +//================================================================================================== +/// The input values of one task instance in a job. There is one per task in the workflow; tasks +/// without config fields have no bindings and are hidden. +//================================================================================================== class RimWorkflowTaskInput : public caf::PdmObjectCollection { CAF_PDM_HEADER_INIT; @@ -33,10 +40,18 @@ public: QString taskName() const; void setTaskName( const QString& name ); + QString taskType() const; + void setTaskInfo( const QString& taskType, const QString& description ); void buildFromSchema( const QJsonArray& configFields ); + void syncFromSchema( const QJsonArray& configFields, QMap& detachedValues ); - QString toTaskYamlBlock() const; + QJsonObject jsonValues() const; + QJsonObject literalValues() const; + + static QString bindingClassKeyword( const QJsonObject& fieldSchema ); + static RimWorkflowFieldBinding* createBinding( const QJsonObject& fieldSchema ); + static QString detachedKey( const QString& fieldName, const QString& classKeyword ); protected: void defineUiOrdering( QString uiConfigName, caf::PdmUiOrdering& uiOrdering ) override; @@ -44,4 +59,6 @@ protected: private: caf::PdmField m_taskName; + caf::PdmField m_taskType; + caf::PdmField m_taskDescription; }; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowValidationTools.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowValidationTools.cpp new file mode 100644 index 0000000000..5240be190b --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowValidationTools.cpp @@ -0,0 +1,112 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RimWorkflowValidationTools.h" + +#include + +namespace +{ +RimWorkflowIssue errorIssue( const QString& task, const QString& field, const QString& message ) +{ + return { .task = task, .field = field, .severity = RimWorkflowIssue::Severity::Error, .message = message }; +} + +void appendErrorIssues( const QJsonObject& error, const QStringList& nodeNames, std::vector& issues ) +{ + QString message = error.value( "message" ).toString(); + if ( message.isEmpty() ) message = "The workflow is not valid"; + + QString task = error.value( "task" ).toString(); + if ( !nodeNames.contains( task ) ) task = RimWorkflowValidationTools::taskFromMessage( message, nodeNames ); + + const QJsonArray fieldIssues = error.value( "issues" ).toArray(); + if ( fieldIssues.isEmpty() || task.isEmpty() ) + { + issues.push_back( errorIssue( task, error.value( "field" ).toString(), message ) ); + return; + } + + for ( const QJsonValue& value : fieldIssues ) + { + const QJsonObject fieldIssue = value.toObject(); + const QString code = fieldIssue.value( "code" ).toString(); + issues.push_back( + errorIssue( task, fieldIssue.value( "field" ).toString(), code.isEmpty() ? message : QString( "%1 (%2)" ).arg( message, code ) ) ); + } +} +} // namespace + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RimWorkflowValidationTools::Result RimWorkflowValidationTools::issuesFromHelperResult( const QJsonObject& helperResult, + const QStringList& nodeNames ) +{ + if ( helperResult.value( "status" ).toString() == "invalid" ) + { + Result result; + result.describeSucceeded = false; + appendErrorIssues( helperResult.value( "error" ).toObject(), nodeNames, result.issues ); + return result; + } + return issuesFromDescribe( helperResult.value( "describe" ).toObject(), helperResult.value( "describe_error" ).toObject(), nodeNames ); +} + +//-------------------------------------------------------------------------------------------------- +/// Errors from describe, and warnings for config fields without a value +//-------------------------------------------------------------------------------------------------- +RimWorkflowValidationTools::Result RimWorkflowValidationTools::issuesFromDescribe( const QJsonObject& describe, + const QJsonObject& describeError, + const QStringList& nodeNames ) +{ + Result result; + if ( !describeError.isEmpty() ) + { + result.describeSucceeded = false; + appendErrorIssues( describeError, nodeNames, result.issues ); + return result; + } + + for ( const QJsonValue& value : describe.value( "tasks" ).toArray() ) + { + const QJsonObject task = value.toObject(); + for ( const QJsonValue& field : task.value( "missing_config_fields" ).toArray() ) + { + result.issues.push_back( { .task = task.value( "name" ).toString(), + .field = field.toString(), + .severity = RimWorkflowIssue::Severity::Warning, + .message = QString( "Input '%1' has no value" ).arg( field.toString() ) } ); + } + } + return result; +} + +//-------------------------------------------------------------------------------------------------- +/// The node whose name is quoted in the message, preferring the longest name +//-------------------------------------------------------------------------------------------------- +QString RimWorkflowValidationTools::taskFromMessage( const QString& message, const QStringList& nodeNames ) +{ + QString found; + for ( const QString& name : nodeNames ) + { + const bool quoted = message.contains( QString( "'%1'" ).arg( name ) ) || message.contains( QString( "\"%1\"" ).arg( name ) ); + if ( quoted && name.size() > found.size() ) found = name; + } + return found; +} diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowValidationTools.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowValidationTools.h new file mode 100644 index 0000000000..66e5f0b0fa --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowValidationTools.h @@ -0,0 +1,43 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "RimWorkflowDefinition.h" + +#include +#include + +#include + +//================================================================================================== +/// Turns the result of `taskmaestro_helper save --describe` (or `load --describe`) into issues +/// attached to the tasks of the workflow. Issues with an empty task belong to the workflow. +//================================================================================================== +namespace RimWorkflowValidationTools +{ +struct Result +{ + std::vector issues; + bool describeSucceeded = true; +}; + +Result issuesFromHelperResult( const QJsonObject& helperResult, const QStringList& nodeNames ); +Result issuesFromDescribe( const QJsonObject& describe, const QJsonObject& describeError, const QStringList& nodeNames ); +QString taskFromMessage( const QString& message, const QStringList& nodeNames ); +} // namespace RimWorkflowValidationTools diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowVec3Binding.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowVec3Binding.cpp index 6ececb91aa..fc6e9ffebd 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowVec3Binding.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowVec3Binding.cpp @@ -50,13 +50,10 @@ void RimWorkflowVec3Binding::applySchema( const QJsonObject& fieldSchema ) //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -QString RimWorkflowVec3Binding::toYamlValue() const +QJsonValue RimWorkflowVec3Binding::toJsonValue() const { - if ( !hasValue() ) return "null"; + if ( !hasValue() ) return QJsonValue( QJsonValue::Null ); const cvf::Vec3d& v = m_value(); - return QString( "{x: %1, y: %2, z: %3}" ) - .arg( QString::number( v.x(), 'g', 17 ) ) - .arg( QString::number( v.y(), 'g', 17 ) ) - .arg( QString::number( v.z(), 'g', 17 ) ); + return QJsonObject{ { "x", v.x() }, { "y", v.y() }, { "z", v.z() } }; } diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowVec3Binding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowVec3Binding.h index 4965312770..86fd586e28 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowVec3Binding.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowVec3Binding.h @@ -31,8 +31,8 @@ class RimWorkflowVec3Binding : public RimWorkflowFieldBinding public: RimWorkflowVec3Binding(); - void applySchema( const QJsonObject& fieldSchema ) override; - QString toYamlValue() const override; + void applySchema( const QJsonObject& fieldSchema ) override; + QJsonValue toJsonValue() const override; caf::PdmFieldHandle* valueField() override { return &m_value; } diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowViewBinding.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowViewBinding.cpp index 79604480bc..bc6fbdab23 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowViewBinding.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowViewBinding.cpp @@ -23,6 +23,8 @@ #include "RimEclipseView.h" #include "RimProject.h" +#include + CAF_PDM_SOURCE_INIT( RimWorkflowViewBinding, "WorkflowViewBinding" ); //-------------------------------------------------------------------------------------------------- @@ -44,10 +46,10 @@ QString RimWorkflowViewBinding::displayValue() const //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -QString RimWorkflowViewBinding::toYamlValue() const +QJsonValue RimWorkflowViewBinding::toJsonValue() const { - if ( m_view() == nullptr ) return "null"; - return QString( "{__resinsight_ref__: View, view_id: %1}" ).arg( m_view()->id() ); + if ( m_view() == nullptr ) return QJsonValue::Null; + return QJsonObject{ { "__resinsight_ref__", "View" }, { "view_id", m_view()->id() } }; } //-------------------------------------------------------------------------------------------------- @@ -73,3 +75,31 @@ QList RimWorkflowViewBinding::calculateValueOptions( con } return options; } + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RimWorkflowViewBinding::isObjectReference() const +{ + return true; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RimWorkflowViewBinding::applySchema( const QJsonObject& fieldSchema ) +{ + RimWorkflowFieldBinding::applySchema( fieldSchema ); + const QJsonObject reference = fieldSchema.value( "default" ).toObject(); + if ( reference.value( "__resinsight_ref__" ).toString() != "View" || !reference.contains( "view_id" ) ) return; + + const int viewId = reference.value( "view_id" ).toInt( -1 ); + for ( RimEclipseCase* eclipseCase : RimEclipseCaseTools::eclipseCases() ) + { + if ( !eclipseCase ) continue; + for ( RimEclipseView* view : eclipseCase->reservoirViews() ) + { + if ( view && view->id() == viewId ) m_view = view; + } + } +} diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowViewBinding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowViewBinding.h index b10b07eea6..b334440c90 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowViewBinding.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowViewBinding.h @@ -31,8 +31,10 @@ class RimWorkflowViewBinding : public RimWorkflowFieldBinding public: RimWorkflowViewBinding(); - QString toYamlValue() const override; - QString displayValue() const override; + QJsonValue toJsonValue() const override; + bool isObjectReference() const override; + void applySchema( const QJsonObject& fieldSchema ) 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 39d667645f..a15590e177 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowWellPathBinding.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowWellPathBinding.cpp @@ -18,9 +18,12 @@ #include "RimWorkflowWellPathBinding.h" +#include "RimProject.h" #include "RimTools.h" #include "RimWellPath.h" +#include + CAF_PDM_SOURCE_INIT( RimWorkflowWellPathBinding, "WorkflowWellPathBinding" ); //-------------------------------------------------------------------------------------------------- @@ -42,11 +45,10 @@ QString RimWorkflowWellPathBinding::displayValue() const //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -QString RimWorkflowWellPathBinding::toYamlValue() const +QJsonValue RimWorkflowWellPathBinding::toJsonValue() const { - if ( m_wellPath() == nullptr ) return "null"; - const QString name = yamlQuotedScalar( m_wellPath()->name() ); - return QString( "{__resinsight_ref__: WellPath, well_path_name: %1}" ).arg( name ); + if ( m_wellPath() == nullptr ) return QJsonValue::Null; + return QJsonObject{ { "__resinsight_ref__", "WellPath" }, { "well_path_name", m_wellPath()->name() } }; } //-------------------------------------------------------------------------------------------------- @@ -58,3 +60,29 @@ QList RimWorkflowWellPathBinding::calculateValueOptions( if ( fieldNeedingOptions == &m_wellPath ) RimTools::wellPathOptionItems( &options ); return options; } + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RimWorkflowWellPathBinding::isObjectReference() const +{ + return true; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RimWorkflowWellPathBinding::applySchema( const QJsonObject& fieldSchema ) +{ + RimWorkflowFieldBinding::applySchema( fieldSchema ); + const QJsonObject reference = fieldSchema.value( "default" ).toObject(); + if ( reference.value( "__resinsight_ref__" ).toString() != "WellPath" || !reference.contains( "well_path_name" ) ) return; + + auto* project = RimProject::current(); + if ( !project ) return; + const QString name = reference.value( "well_path_name" ).toString(); + for ( RimWellPath* wellPath : project->allWellPaths() ) + { + if ( wellPath && wellPath->name() == name ) m_wellPath = wellPath; + } +} diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowWellPathBinding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowWellPathBinding.h index 7ed96f07f6..77ca73c8a6 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowWellPathBinding.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowWellPathBinding.h @@ -31,8 +31,10 @@ class RimWorkflowWellPathBinding : public RimWorkflowFieldBinding public: RimWorkflowWellPathBinding(); - QString toYamlValue() const override; - QString displayValue() const override; + QJsonValue toJsonValue() const override; + bool isObjectReference() const override; + void applySchema( const QJsonObject& fieldSchema ) override; + QString displayValue() const override; caf::PdmFieldHandle* valueField() override { return &m_wellPath; } diff --git a/ApplicationLibCode/UnitTests/CMakeLists.txt b/ApplicationLibCode/UnitTests/CMakeLists.txt index a219be4f65..424f81ff42 100644 --- a/ApplicationLibCode/UnitTests/CMakeLists.txt +++ b/ApplicationLibCode/UnitTests/CMakeLists.txt @@ -102,6 +102,9 @@ set(SOURCE_UNITTEST_FILES ${CMAKE_CURRENT_LIST_DIR}/RimWellTargetMapping-Test.cpp ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowDescribeTools-Test.cpp ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowVec3Binding-Test.cpp + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowDefinition-Test.cpp + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowJobSync-Test.cpp + ${CMAKE_CURRENT_LIST_DIR}/RiuWorkflowGraphLayout-Test.cpp ${CMAKE_CURRENT_LIST_DIR}/RimDataFilterCollection-Test.cpp ${CMAKE_CURRENT_LIST_DIR}/RimEclipseContourMapProjection-Test.cpp ${CMAKE_CURRENT_LIST_DIR}/RimIntersectionCollection-Test.cpp diff --git a/ApplicationLibCode/UnitTests/RimWorkflowDefinition-Test.cpp b/ApplicationLibCode/UnitTests/RimWorkflowDefinition-Test.cpp new file mode 100644 index 0000000000..af50463239 --- /dev/null +++ b/ApplicationLibCode/UnitTests/RimWorkflowDefinition-Test.cpp @@ -0,0 +1,352 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "gtest/gtest.h" + +#include "RiaTestDataDirectory.h" +#include "RimWorkflowDefinitionTools.h" +#include "RimWorkflowPortCompatibility.h" +#include "RimWorkflowTaskCatalog.h" + +#include +#include +#include +#include + +// +// Fixtures in TestData/RimWorkflowDefinition are produced by the Python helper (run from +// GrpcInterface/Python, with absolute paths replaced by `test_data/taskmaestro_workflows`): +// python -m rips.taskmaestro_helper load rips/tests/test_data/taskmaestro_workflows/resinsight_completions --describe > completions.json +// python -m rips.taskmaestro_helper load rips/tests/test_data/taskmaestro_workflows/resinsight_load_data --describe > load_data.json +// python -m rips.taskmaestro_helper catalog > catalog.json +// +namespace +{ +QJsonObject fixture( const QString& name ) +{ + QFile file( QString( "%1/RimWorkflowDefinition/%2.json" ).arg( TEST_DATA_DIR ).arg( name ) ); + if ( !file.open( QIODevice::ReadOnly ) ) return {}; + return QJsonDocument::fromJson( file.readAll() ).object(); +} + +RimWorkflowDefinition definitionFixture( const QString& name ) +{ + auto definition = RimWorkflowDefinitionTools::fromJson( fixture( name ) ); + EXPECT_TRUE( definition.has_value() ); + return definition.value_or( RimWorkflowDefinition() ); +} + +RimWorkflowTaskCatalog catalogFixture() +{ + return RimWorkflowTaskCatalog::fromJson( fixture( "catalog" ) ); +} + +QJsonObject taskByName( const QJsonObject& graph, const QString& name ) +{ + for ( const QJsonValue& task : graph.value( "tasks" ).toArray() ) + { + if ( task.toObject().value( "name" ).toString() == name ) return task.toObject(); + } + return {}; +} + +RimWorkflowDefinition edited( const RimWorkflowDefinitionTools::EditResult& result ) +{ + EXPECT_TRUE( result.has_value() ) << ( result ? "" : result.error().toStdString() ); + return result.value_or( RimWorkflowDefinition() ); +} + +bool hasEdge( const RimWorkflowDefinition& definition, const RimWorkflowDefinitionEdge& edge ) +{ + return std::find( definition.edges.begin(), definition.edges.end(), edge ) != definition.edges.end(); +} + +QStringList issueMessages( const std::vector& issues ) +{ + QStringList messages; + for ( const auto& issue : issues ) + messages.append( QString( "%1.%2: %3" ).arg( issue.task, issue.field, issue.message ) ); + return messages; +} +} // namespace + +//-------------------------------------------------------------------------------------------------- +TEST( RimWorkflowDefinition, fromJson ) +{ + const auto definition = definitionFixture( "completions" ); + EXPECT_EQ( "resinsight_completions", definition.name ); + EXPECT_TRUE( definition.editable ); + ASSERT_EQ( 7u, definition.nodes.size() ); + EXPECT_EQ( 13u, definition.edges.size() ); + EXPECT_TRUE( definition.resultTask.isEmpty() ); + + const auto* addPerf = definition.findNode( "add_perf_1" ); + ASSERT_TRUE( addPerf ); + EXPECT_EQ( "resinsight.add_perforation", addPerf->taskId ); + EXPECT_EQ( QStringList( { "event_date", "start_md", "end_md" } ), addPerf->configFields ); + EXPECT_EQ( addPerf->configFields, addPerf->explicitConfigFields ); + EXPECT_FALSE( definition.describe.isEmpty() ); + + EXPECT_FALSE( RimWorkflowDefinitionTools::fromJson( QJsonObject{ { "format", 2 }, { "nodes", QJsonArray() } } ).has_value() ); + EXPECT_FALSE( RimWorkflowDefinitionTools::fromJson( QJsonObject{ { "name", "x" } } ).has_value() ); +} + +//-------------------------------------------------------------------------------------------------- +TEST( RimWorkflowDefinition, jsonRoundTrip ) +{ + for ( const QString& name : { "completions", "load_data" } ) + { + const auto definition = definitionFixture( name ); + const auto roundTrip = RimWorkflowDefinitionTools::fromJson( RimWorkflowDefinitionTools::toJson( definition ) ); + ASSERT_TRUE( roundTrip.has_value() ); + EXPECT_EQ( definition, roundTrip.value() ) << name.toStdString(); + } +} + +//-------------------------------------------------------------------------------------------------- +TEST( RimWorkflowDefinition, normalizeKeepsLoadedExamples ) +{ + for ( const QString& name : { "completions", "load_data" } ) + { + const auto definition = definitionFixture( name ); + const auto normalized = RimWorkflowDefinitionTools::normalize( definition ); + for ( size_t i = 0; i < definition.nodes.size(); ++i ) + { + EXPECT_EQ( definition.nodes[i].configFields, normalized.nodes[i].configFields ) << definition.nodes[i].name.toStdString(); + } + EXPECT_EQ( normalized, RimWorkflowDefinitionTools::normalize( normalized ) ); + EXPECT_TRUE( RimWorkflowDefinitionTools::validate( definition ).empty() ) + << issueMessages( RimWorkflowDefinitionTools::validate( definition ) ).join( "\n" ).toStdString(); + } +} + +//-------------------------------------------------------------------------------------------------- +TEST( RimWorkflowDefinition, graphFromDefinition ) +{ + const auto definition = definitionFixture( "completions" ); + const auto graph = RimWorkflowDefinitionTools::graphFromDefinition( definition, true ); + EXPECT_EQ( "resinsight_completions", graph.value( "name" ).toString() ); + EXPECT_TRUE( graph.value( "editable" ).toBool() ); + EXPECT_EQ( "export_completions", graph.value( "result_task" ).toString() ); + EXPECT_EQ( 7, graph.value( "tasks" ).toArray().size() ); + EXPECT_EQ( 13, graph.value( "edges" ).toArray().size() ); + EXPECT_TRUE( graph.value( "issues" ).toArray().isEmpty() ); + + const QJsonObject exportTask = taskByName( graph, "export_completions" ); + EXPECT_TRUE( exportTask.value( "is_result" ).toBool() ); + EXPECT_EQ( 2, exportTask.value( "config_fields" ).toArray().size() ); + EXPECT_EQ( QJsonArray( { "export_file", "well_path_names" } ), exportTask.value( "outputs" ).toArray() ); + + // The whole input port comes first, followed by the fields + const QJsonArray ports = exportTask.value( "input_ports" ).toArray(); + ASSERT_EQ( 7, ports.size() ); + EXPECT_EQ( "", ports[0].toObject().value( "name" ).toString() ); + EXPECT_EQ( "resinsight", ports[1].toObject().value( "name" ).toString() ); + EXPECT_TRUE( ports[1].toObject().value( "wired" ).toBool() ); + + // The connect task's opaque `value` is not a port of its own + const QJsonObject connect = taskByName( graph, "connect_to_resinsight" ); + EXPECT_TRUE( connect.value( "outputs" ).toArray().isEmpty() ); + EXPECT_FALSE( connect.value( "accepts_input" ).toBool() ); + + EXPECT_FALSE( RimWorkflowDefinitionTools::graphFromDefinition( definition, false ).value( "editable" ).toBool() ); +} + +//-------------------------------------------------------------------------------------------------- +TEST( RimWorkflowPortCompatibility, fieldConnections ) +{ + const auto definition = definitionFixture( "completions" ); + using RimWorkflowPortCompatibility::canConnect; + + EXPECT_TRUE( canConnect( definition, "connect_to_resinsight", "", "select_eclipse_case", "resinsight" ) ); + EXPECT_TRUE( canConnect( definition, "select_eclipse_case", "", "select_well_path_1", "grid_case" ) ); + + // A GridCase is not a RipsInstance + EXPECT_FALSE( canConnect( definition, "select_eclipse_case", "", "select_well_path_1", "resinsight" ) ); + // A perforation is not a well path + EXPECT_FALSE( canConnect( definition, "add_perf_1", "", "add_perf_2", "well_path" ) ); + // Cycles and self-loops + EXPECT_FALSE( canConnect( definition, "export_completions", "", "select_eclipse_case", "case" ) ); + EXPECT_FALSE( canConnect( definition, "add_perf_1", "", "add_perf_1", "well_path" ) ); + // Unknown ports and tasks + EXPECT_FALSE( canConnect( definition, "select_eclipse_case", "", "select_well_path_1", "no_such_input" ) ); + EXPECT_FALSE( canConnect( definition, "no_such_task", "", "select_well_path_1", "grid_case" ) ); + // A task cannot take both its whole input and fields from other tasks + EXPECT_FALSE( canConnect( definition, "select_well_path_1", "", "add_perf_1", "" ) ); +} + +//-------------------------------------------------------------------------------------------------- +TEST( RimWorkflowPortCompatibility, wholeConnections ) +{ + const auto catalog = catalogFixture(); + auto definition = RimWorkflowDefinitionTools::createEmpty( "test" ); + for ( const QString& taskId : { "resinsight.connect", "resinsight.load_model", "resinsight.close_project", "resinsight.exit" } ) + { + definition.taskTypes.insert( taskId, catalog.taskType( taskId ) ); + definition.nodes.push_back( { .name = taskId.mid( 11 ), .taskId = taskId } ); + } + using RimWorkflowPortCompatibility::canConnect; + + // RipsInstance -> close_project (RipsInstance) and exit (RipsInstance) + EXPECT_TRUE( canConnect( definition, "connect", "", "close_project", "" ) ); + EXPECT_TRUE( canConnect( definition, "close_project", "", "exit", "" ) ); + // GridCase -> RipsInstance is not compatible + EXPECT_FALSE( canConnect( definition, "load_model", "", "close_project", "" ) ); + // Whole RipsInstance into a field input + EXPECT_TRUE( canConnect( definition, "connect", "", "load_model", "resinsight" ) ); +} + +//-------------------------------------------------------------------------------------------------- +TEST( RimWorkflowDefinitionEdit, addTaskWiresResInsight ) +{ + const auto catalog = catalogFixture(); + auto definition = RimWorkflowDefinitionTools::createEmpty( "new_workflow" ); + + QString added; + definition = edited( RimWorkflowDefinitionTools::addTask( definition, catalog, "resinsight.load_model", &added ) ); + EXPECT_EQ( "load_model", added ); + ASSERT_EQ( 2u, definition.nodes.size() ); + EXPECT_EQ( "connect_to_resinsight", definition.nodes[0].name ); + EXPECT_TRUE( hasEdge( definition, { .from = "connect_to_resinsight", .to = "load_model", .input = "resinsight" } ) ); + EXPECT_EQ( QStringList( { "path" } ), definition.findNode( "load_model" )->configFields ); + EXPECT_TRUE( definition.inputs.contains( "load_model" ) ); + EXPECT_TRUE( definition.taskTypes.contains( "resinsight.load_model" ) ); + + // A second instance gets a unique name and reuses the connect task + definition = edited( RimWorkflowDefinitionTools::addTask( definition, catalog, "resinsight.load_model", &added ) ); + EXPECT_EQ( "load_model_2", added ); + EXPECT_EQ( 3u, definition.nodes.size() ); + EXPECT_TRUE( hasEdge( definition, { .from = "connect_to_resinsight", .to = "load_model_2", .input = "resinsight" } ) ); + + // Two end tasks and no result task + auto issues = RimWorkflowDefinitionTools::validate( definition ); + ASSERT_EQ( 1u, issues.size() ) << issueMessages( issues ).join( "\n" ).toStdString(); + EXPECT_TRUE( issues[0].task.isEmpty() ); + + definition = edited( RimWorkflowDefinitionTools::setResultTask( definition, "load_model_2" ) ); + EXPECT_TRUE( RimWorkflowDefinitionTools::validate( definition ).empty() ); + + // The exit task takes the whole RipsInstance + definition = edited( RimWorkflowDefinitionTools::addTask( definition, catalog, "resinsight.exit", &added ) ); + EXPECT_EQ( "exit_resinsight", added ); + EXPECT_TRUE( hasEdge( definition, { .from = "connect_to_resinsight", .to = "exit_resinsight" } ) ); + EXPECT_TRUE( definition.findNode( "exit_resinsight" )->configFields.isEmpty() ); + + EXPECT_FALSE( RimWorkflowDefinitionTools::addTask( definition, catalog, "no.such.task" ).has_value() ); +} + +//-------------------------------------------------------------------------------------------------- +TEST( RimWorkflowDefinitionEdit, connectionsUpdateConfigFields ) +{ + auto definition = definitionFixture( "completions" ); + const RimWorkflowDefinitionEdge gridCase{ .from = "select_eclipse_case", .to = "select_well_path_1", .input = "grid_case" }; + + // A disconnected required input that can be entered in ResInsight becomes a config field + definition = edited( RimWorkflowDefinitionTools::disconnect( definition, gridCase ) ); + EXPECT_EQ( QStringList( { "well_path", "grid_case" } ), definition.findNode( "select_well_path_1" )->configFields ); + EXPECT_FALSE( RimWorkflowDefinitionTools::disconnect( definition, gridCase ).has_value() ); + + definition = edited( RimWorkflowDefinitionTools::connect( definition, gridCase ) ); + EXPECT_EQ( QStringList( { "well_path" } ), definition.findNode( "select_well_path_1" )->configFields ); + + // Connecting an input that is already connected replaces the connection + definition = edited( + RimWorkflowDefinitionTools::connect( definition, { .from = "add_perf_2", .to = "export_completions", .input = "perforation_1" } ) ); + EXPECT_FALSE( hasEdge( definition, { .from = "add_perf_1", .to = "export_completions", .input = "perforation_1" } ) ); + EXPECT_EQ( 13u, definition.edges.size() ); + + // add_perf_1 is now an end task, and the perforation input of export is not configurable + auto issues = RimWorkflowDefinitionTools::validate( definition ); + EXPECT_EQ( 1u, issues.size() ) << issueMessages( issues ).join( "\n" ).toStdString(); + + definition = edited( + RimWorkflowDefinitionTools::disconnect( definition, { .from = "add_perf_2", .to = "export_completions", .input = "perforation_1" } ) ); + issues = RimWorkflowDefinitionTools::validate( definition ); + bool mustConnect = false; + for ( const auto& issue : issues ) + mustConnect = mustConnect || ( issue.task == "export_completions" && issue.field == "perforation_1" ); + EXPECT_TRUE( mustConnect ) << issueMessages( issues ).join( "\n" ).toStdString(); + + const auto graph = RimWorkflowDefinitionTools::graphFromDefinition( definition, true ); + EXPECT_FALSE( taskByName( graph, "export_completions" ).value( "issues" ).toArray().isEmpty() ); +} + +//-------------------------------------------------------------------------------------------------- +TEST( RimWorkflowDefinitionEdit, removeAndRename ) +{ + auto definition = definitionFixture( "load_data" ); + EXPECT_EQ( "import_grid_property", definition.resultTask ); + + definition.inputs["load_model"] = QJsonObject{ { "path", "/data/grid.roff" } }; + definition = edited( RimWorkflowDefinitionTools::renameTask( definition, "load_model", "load_grid" ) ); + EXPECT_TRUE( definition.findNode( "load_grid" ) ); + EXPECT_FALSE( definition.inputs.contains( "load_model" ) ); + EXPECT_EQ( "/data/grid.roff", definition.inputs["load_grid"].toObject().value( "path" ).toString() ); + EXPECT_TRUE( hasEdge( definition, { .from = "load_grid", .to = "import_grid_property", .input = "grid_case" } ) ); + + definition = edited( RimWorkflowDefinitionTools::renameTask( definition, "import_grid_property", "import_props" ) ); + EXPECT_EQ( "import_props", definition.resultTask ); + + EXPECT_FALSE( RimWorkflowDefinitionTools::renameTask( definition, "load_grid", "import_props" ).has_value() ); + EXPECT_FALSE( RimWorkflowDefinitionTools::renameTask( definition, "load_grid", "2bad" ).has_value() ); + EXPECT_FALSE( RimWorkflowDefinitionTools::renameTask( definition, "load_grid", "with space" ).has_value() ); + EXPECT_FALSE( RimWorkflowDefinitionTools::renameWorkflow( definition, "bad-name" ).has_value() ); + EXPECT_EQ( "renamed", edited( RimWorkflowDefinitionTools::renameWorkflow( definition, "renamed" ) ).name ); + + definition = edited( RimWorkflowDefinitionTools::removeTask( definition, "import_props" ) ); + EXPECT_TRUE( definition.resultTask.isEmpty() ); + EXPECT_FALSE( definition.inputs.contains( "import_props" ) ); + for ( const auto& edge : definition.edges ) + EXPECT_NE( "import_props", edge.to ); +} + +//-------------------------------------------------------------------------------------------------- +TEST( RimWorkflowDefinitionEdit, optionalInputs ) +{ + const auto catalog = catalogFixture(); + auto definition = RimWorkflowDefinitionTools::createEmpty( "views" ); + QString added; + definition = edited( RimWorkflowDefinitionTools::addTask( definition, catalog, "resinsight.configure_grid_view", &added ) ); + EXPECT_FALSE( definition.findNode( added )->configFields.contains( "time_step" ) ); + + definition = edited( RimWorkflowDefinitionTools::setOptionalInputConfigured( definition, added, "time_step", true ) ); + EXPECT_TRUE( definition.findNode( added )->configFields.contains( "time_step" ) ); + definition = edited( RimWorkflowDefinitionTools::setOptionalInputConfigured( definition, added, "time_step", false ) ); + EXPECT_FALSE( definition.findNode( added )->configFields.contains( "time_step" ) ); + + EXPECT_FALSE( RimWorkflowDefinitionTools::setOptionalInputConfigured( definition, added, "result_type", true ).has_value() ); +} + +//-------------------------------------------------------------------------------------------------- +TEST( RimWorkflowDefinitionEdit, readOnlyDefinitionsAreNotEdited ) +{ + auto definition = definitionFixture( "load_data" ); + definition.editable = false; + EXPECT_FALSE( RimWorkflowDefinitionTools::removeTask( definition, "load_model" ).has_value() ); + EXPECT_FALSE( RimWorkflowDefinitionTools::renameWorkflow( definition, "other" ).has_value() ); +} + +//-------------------------------------------------------------------------------------------------- +TEST( RimWorkflowDefinitionEdit, names ) +{ + EXPECT_TRUE( RimWorkflowDefinitionTools::isIdentifier( "load_model_2" ) ); + EXPECT_FALSE( RimWorkflowDefinitionTools::isIdentifier( "2load" ) ); + EXPECT_FALSE( RimWorkflowDefinitionTools::isIdentifier( "" ) ); + EXPECT_EQ( "resinsight_load_model", RimWorkflowDefinitionTools::identifierFrom( "resinsight.load-model" ) ); + EXPECT_EQ( "_3d", RimWorkflowDefinitionTools::identifierFrom( "3d" ) ); +} diff --git a/ApplicationLibCode/UnitTests/RimWorkflowJobSync-Test.cpp b/ApplicationLibCode/UnitTests/RimWorkflowJobSync-Test.cpp new file mode 100644 index 0000000000..ae757a939c --- /dev/null +++ b/ApplicationLibCode/UnitTests/RimWorkflowJobSync-Test.cpp @@ -0,0 +1,243 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "gtest/gtest.h" + +#include "RiaTestDataDirectory.h" +#include "RimWorkflow.h" +#include "RimWorkflowDefinitionTools.h" +#include "RimWorkflowJob.h" +#include "RimWorkflowTaskInput.h" +#include "RimWorkflowValidationTools.h" + +#include +#include +#include +#include +#include + +namespace +{ +RimWorkflowDefinition loadDataDefinition() +{ + QFile file( QString( "%1/RimWorkflowDefinition/load_data.json" ).arg( TEST_DATA_DIR ) ); + EXPECT_TRUE( file.open( QIODevice::ReadOnly ) ); + auto definition = RimWorkflowDefinitionTools::fromJson( QJsonDocument::fromJson( file.readAll() ).object() ); + EXPECT_TRUE( definition.has_value() ); + return definition.value_or( RimWorkflowDefinition() ); +} + +QJsonArray graphTasks( const RimWorkflowDefinition& definition ) +{ + return RimWorkflowDefinitionTools::graphFromDefinition( definition, true ).value( "tasks" ).toArray(); +} + +QString pathValue( const RimWorkflowJob& job, const QString& taskName ) +{ + return job.inputValues().value( taskName ).toObject().value( "path" ).toString(); +} +} // namespace + +//-------------------------------------------------------------------------------------------------- +TEST( RimWorkflowJobSync, oneTaskInputPerTask ) +{ + const auto definition = loadDataDefinition(); + RimWorkflowJob job; + job.syncTaskInputs( graphTasks( definition ) ); + + ASSERT_EQ( 4u, job.taskInputs().size() ); + EXPECT_EQ( "connect_to_resinsight", job.taskInputs()[0]->taskName() ); + EXPECT_EQ( 0u, job.taskInputs()[0]->count() ); + EXPECT_EQ( "resinsight.load_model", job.taskInput( "load_model" )->taskType() ); + EXPECT_EQ( "/path/to/grid.roff", pathValue( job, "load_model" ) ); + + // Tasks without config fields are not written + EXPECT_FALSE( job.inputValues().contains( "connect_to_resinsight" ) ); + EXPECT_EQ( QJsonArray( { "/path/to/property.roffasc" } ), + job.literalInputValues().value( "import_grid_property" ).toObject().value( "paths" ).toArray() ); + + QTemporaryDir directory; + const QString path = job.writeInputYaml( directory.filePath( "input.yaml" ) ); + QFile file( path ); + ASSERT_TRUE( file.open( QIODevice::ReadOnly ) ); + EXPECT_EQ( job.inputValues(), QJsonDocument::fromJson( file.readAll() ).object() ); +} + +//-------------------------------------------------------------------------------------------------- +TEST( RimWorkflowJobSync, valuesFollowRenamesAndRemovals ) +{ + auto definition = loadDataDefinition(); + RimWorkflowJob job; + job.syncTaskInputs( graphTasks( definition ) ); + RimWorkflowTaskInput* loadModel = job.taskInput( "load_model" ); + + // The binding object and its value are kept when the task is renamed + auto renamed = RimWorkflowDefinitionTools::renameTask( definition, "load_model", "load_grid" ); + ASSERT_TRUE( renamed.has_value() ); + renamed->inputs["load_grid"] = QJsonObject(); + job.syncTaskInputs( graphTasks( renamed.value() ), { { "load_model", "load_grid" } } ); + EXPECT_EQ( loadModel, job.taskInput( "load_grid" ) ); + EXPECT_EQ( "/path/to/grid.roff", pathValue( job, "load_grid" ) ); + + // Removing the task keeps its values aside, so undo brings them back + auto removed = RimWorkflowDefinitionTools::removeTask( renamed.value(), "load_grid" ); + ASSERT_TRUE( removed.has_value() ); + job.syncTaskInputs( graphTasks( removed.value() ) ); + EXPECT_EQ( nullptr, job.taskInput( "load_grid" ) ); + + job.syncTaskInputs( graphTasks( renamed.value() ) ); + EXPECT_EQ( "/path/to/grid.roff", pathValue( job, "load_grid" ) ); + + // And through a rename back + job.syncTaskInputs( graphTasks( removed.value() ) ); + auto restored = renamed.value(); + ASSERT_TRUE( restored.findNode( "load_grid" ) ); + restored.findNode( "load_grid" )->name = "load_model"; + restored.inputs.remove( "load_grid" ); + for ( auto& edge : restored.edges ) + { + if ( edge.from == "load_grid" ) edge.from = "load_model"; + } + job.syncTaskInputs( graphTasks( removed.value() ), { { "load_grid", "load_model" } } ); + job.syncTaskInputs( graphTasks( restored ) ); + EXPECT_EQ( "/path/to/grid.roff", pathValue( job, "load_model" ) ); +} + +//-------------------------------------------------------------------------------------------------- +TEST( RimWorkflowEditing, undoRedoAndDirtyState ) +{ + RimWorkflow workflow; + workflow.setUnsavedDefinition( loadDataDefinition() ); + EXPECT_TRUE( workflow.isEditable() ); + EXPECT_TRUE( workflow.isDirty() ); + EXPECT_FALSE( workflow.canUndo() ); + ASSERT_EQ( 1u, workflow.jobs().size() ); + RimWorkflowJob* job = workflow.jobs().front(); + EXPECT_EQ( "/path/to/grid.roff", pathValue( *job, "load_model" ) ); + + const int revision = workflow.revision(); + auto result = workflow.applyEdit( RimWorkflowDefinitionTools::renameTask( workflow.definition(), "load_model", "load_grid" ), + { { "load_model", "load_grid" } } ); + ASSERT_TRUE( result.has_value() ); + EXPECT_GT( workflow.revision(), revision ); + EXPECT_TRUE( workflow.canUndo() ); + EXPECT_EQ( "/path/to/grid.roff", pathValue( *job, "load_grid" ) ); + EXPECT_TRUE( workflow.uiName().endsWith( " *" ) ); + + workflow.undo(); + EXPECT_TRUE( workflow.definition().findNode( "load_model" ) ); + EXPECT_EQ( "/path/to/grid.roff", pathValue( *job, "load_model" ) ); + EXPECT_TRUE( workflow.canRedo() ); + + workflow.redo(); + EXPECT_TRUE( workflow.definition().findNode( "load_grid" ) ); + EXPECT_FALSE( workflow.canRedo() ); + + // Failed edits change nothing + const auto before = workflow.definition(); + EXPECT_FALSE( workflow.applyEdit( RimWorkflowDefinitionTools::renameTask( before, "load_grid", "bad name" ) ).has_value() ); + EXPECT_EQ( before, workflow.definition() ); + + // Read-only workflows cannot be edited + RimWorkflow readOnly; + auto readOnlyDefinition = loadDataDefinition(); + readOnlyDefinition.editable = false; + readOnly.loadFromRegistered( QJsonObject{ { "id", "test.workflow" }, + { "name", "workflow" }, + { "definition", RimWorkflowDefinitionTools::toJson( readOnlyDefinition ) } } ); + EXPECT_FALSE( readOnly.isEditable() ); + EXPECT_FALSE( readOnly.isDirty() ); + EXPECT_FALSE( readOnly.applyEdit( RimWorkflowDefinitionTools::setResultTask( readOnly.definition(), "" ) ).has_value() ); + auto runSource = readOnly.prepareRunSource(); + ASSERT_TRUE( runSource.has_value() ); + EXPECT_EQ( QStringList( { "run", "--registered", "test.workflow" } ), runSource->baseArgs ); +} + +//-------------------------------------------------------------------------------------------------- +TEST( RimWorkflowEditing, editabilityOfInstalledAndDuplicatedWorkflows ) +{ + // An installed workflow is read-only even when its definition is editable, + // but keeps the editable flag so it can be duplicated + RimWorkflow installed; + installed.loadFromRegistered( QJsonObject{ { "id", "test.workflow" }, + { "name", "workflow" }, + { "definition", RimWorkflowDefinitionTools::toJson( loadDataDefinition() ) } } ); + EXPECT_FALSE( installed.isEditable() ); + EXPECT_TRUE( installed.definition().editable ); + EXPECT_FALSE( installed.definition().readOnlyReasons.isEmpty() ); + + RimWorkflow duplicate; + duplicate.setUnsavedDefinition( installed.definition() ); + EXPECT_TRUE( duplicate.isEditable() ); + EXPECT_TRUE( duplicate.definition().readOnlyReasons.isEmpty() ); + + // Unsupported structure (e.g. nested workflows) keeps a duplicate read-only + auto nested = loadDataDefinition(); + nested.nodes.front().extra = QJsonObject{ { "workflow", "other" } }; + const QStringList structural = RimWorkflowDefinitionTools::structuralReadOnlyReasons( nested ); + ASSERT_EQ( 1, structural.size() ); + EXPECT_TRUE( structural.front().contains( "workflow" ) ); + + RimWorkflow nestedWorkflow; + nestedWorkflow.setUnsavedDefinition( nested ); + EXPECT_FALSE( nestedWorkflow.isEditable() ); + EXPECT_EQ( structural, nestedWorkflow.definition().readOnlyReasons ); +} + +//-------------------------------------------------------------------------------------------------- +TEST( RimWorkflowValidationTools, issuesFromHelperResult ) +{ + const QStringList nodes{ "load_model", "load_model_2", "import" }; + + const QJsonObject describeError{ { "message", "Missing config" }, + { "task", "load_model" }, + { "issues", QJsonArray{ QJsonObject{ { "field", "path" }, { "code", "missing" } } } } }; + auto result = + RimWorkflowValidationTools::issuesFromHelperResult( QJsonObject{ { "status", "ok" }, { "describe_error", describeError } }, nodes ); + EXPECT_FALSE( result.describeSucceeded ); + ASSERT_EQ( 1u, result.issues.size() ); + EXPECT_EQ( "load_model", result.issues[0].task ); + EXPECT_EQ( "path", result.issues[0].field ); + EXPECT_EQ( RimWorkflowIssue::Severity::Error, result.issues[0].severity ); + + // The task is found from a quoted name in the message, preferring the longest match + result = + RimWorkflowValidationTools::issuesFromHelperResult( QJsonObject{ { "status", "invalid" }, + { "error", + QJsonObject{ { "message", "Task 'load_model_2' has a bad input" } } } }, + nodes ); + ASSERT_EQ( 1u, result.issues.size() ); + EXPECT_EQ( "load_model_2", result.issues[0].task ); + + // Unknown tasks go to the workflow + result = RimWorkflowValidationTools::issuesFromHelperResult( QJsonObject{ { "status", "invalid" }, + { "error", + QJsonObject{ { "message", "Broken" }, { "task", "other" } } } }, + nodes ); + ASSERT_EQ( 1u, result.issues.size() ); + EXPECT_TRUE( result.issues[0].task.isEmpty() ); + + // Missing config values are warnings + const QJsonObject describe{ + { "tasks", QJsonArray{ QJsonObject{ { "name", "import" }, { "missing_config_fields", QJsonArray{ "paths" } } } } } }; + result = RimWorkflowValidationTools::issuesFromHelperResult( QJsonObject{ { "status", "ok" }, { "describe", describe } }, nodes ); + EXPECT_TRUE( result.describeSucceeded ); + ASSERT_EQ( 1u, result.issues.size() ); + EXPECT_EQ( RimWorkflowIssue::Severity::Warning, result.issues[0].severity ); + EXPECT_EQ( "paths", result.issues[0].field ); +} diff --git a/ApplicationLibCode/UnitTests/RimWorkflowVec3Binding-Test.cpp b/ApplicationLibCode/UnitTests/RimWorkflowVec3Binding-Test.cpp index 1c678f4ca8..af4c6372a8 100644 --- a/ApplicationLibCode/UnitTests/RimWorkflowVec3Binding-Test.cpp +++ b/ApplicationLibCode/UnitTests/RimWorkflowVec3Binding-Test.cpp @@ -18,16 +18,17 @@ TEST( RimWorkflowVec3Binding, AppliesObjectDefault ) { "default", QJsonObject{ { "x", 457196.0 }, { "y", 7322270.0 }, { "z", 2742.0 } } } }; binding.applySchema( schema ); - EXPECT_EQ( "{x: 457196, y: 7322270, z: 2742}", binding.toYamlValue() ); + const QJsonObject expected{ { "x", 457196.0 }, { "y", 7322270.0 }, { "z", 2742.0 } }; + EXPECT_EQ( QJsonValue( expected ), binding.toJsonValue() ); } //-------------------------------------------------------------------------------------------------- -TEST( RimWorkflowVec3Binding, NoDefaultYieldsNullYaml ) +TEST( RimWorkflowVec3Binding, NoDefaultYieldsNull ) { RimWorkflowVec3Binding binding; const QJsonObject schema{ { "name", "reference_point" }, { "required", true } }; binding.applySchema( schema ); - EXPECT_EQ( "null", binding.toYamlValue() ); + EXPECT_TRUE( binding.toJsonValue().isNull() ); } diff --git a/ApplicationLibCode/UnitTests/RiuWorkflowGraphLayout-Test.cpp b/ApplicationLibCode/UnitTests/RiuWorkflowGraphLayout-Test.cpp new file mode 100644 index 0000000000..59d02811c2 --- /dev/null +++ b/ApplicationLibCode/UnitTests/RiuWorkflowGraphLayout-Test.cpp @@ -0,0 +1,87 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "gtest/gtest.h" + +#include "RiuWorkflowGraphLayout.h" + +using Edges = std::vector>; + +//-------------------------------------------------------------------------------------------------- +TEST( RiuWorkflowGraphLayout, diamond ) +{ + const auto layout = + RiuWorkflowGraphLayout::computeLayers( { "a", "b", "c", "d" }, Edges{ { "a", "b" }, { "a", "c" }, { "b", "d" }, { "c", "d" } } ); + + ASSERT_EQ( 3u, layout.layers.size() ); + EXPECT_EQ( QStringList( { "a" } ), layout.layers[0] ); + EXPECT_EQ( QStringList( { "b", "c" } ), layout.layers[1] ); + EXPECT_EQ( QStringList( { "d" } ), layout.layers[2] ); +} + +//-------------------------------------------------------------------------------------------------- +TEST( RiuWorkflowGraphLayout, nonTopologicalInput ) +{ + const auto layout = + RiuWorkflowGraphLayout::computeLayers( { "d", "c", "b", "a" }, Edges{ { "c", "d" }, { "b", "c" }, { "a", "b" }, { "a", "d" } } ); + + EXPECT_EQ( 0, layout.ranks.value( "a" ) ); + EXPECT_EQ( 1, layout.ranks.value( "b" ) ); + EXPECT_EQ( 2, layout.ranks.value( "c" ) ); + EXPECT_EQ( 3, layout.ranks.value( "d" ) ); +} + +//-------------------------------------------------------------------------------------------------- +TEST( RiuWorkflowGraphLayout, disconnectedNodes ) +{ + const auto layout = RiuWorkflowGraphLayout::computeLayers( { "z", "a", "m" }, Edges{ { "a", "unknown" } } ); + + ASSERT_EQ( 1u, layout.layers.size() ); + EXPECT_EQ( QStringList( { "a", "m", "z" } ), layout.layers[0] ); +} + +//-------------------------------------------------------------------------------------------------- +TEST( RiuWorkflowGraphLayout, cycle ) +{ + const auto layout = RiuWorkflowGraphLayout::computeLayers( { "start", "a", "b", "end" }, + Edges{ { "start", "a" }, { "a", "b" }, { "b", "a" }, { "b", "end" } } ); + + EXPECT_EQ( 4, layout.ranks.size() ); + EXPECT_EQ( 0, layout.ranks.value( "start" ) ); + EXPECT_LT( layout.ranks.value( "a" ), layout.ranks.value( "b" ) ); + EXPECT_LT( layout.ranks.value( "b" ), layout.ranks.value( "end" ) ); + + int nodeCount = 0; + for ( const auto& layer : layout.layers ) + nodeCount += layer.size(); + EXPECT_EQ( 4, nodeCount ); +} + +//-------------------------------------------------------------------------------------------------- +TEST( RiuWorkflowGraphLayout, deterministicOrder ) +{ + const Edges edges{ { "root", "x" }, { "root", "y" }, { "other", "w" }, { "x", "end" } }; + const auto first = RiuWorkflowGraphLayout::computeLayers( { "root", "other", "x", "y", "w", "end" }, edges ); + const auto second = RiuWorkflowGraphLayout::computeLayers( { "end", "w", "y", "x", "other", "root" }, edges ); + + EXPECT_EQ( first.layers, second.layers ); + ASSERT_EQ( 3u, first.layers.size() ); + EXPECT_EQ( QStringList( { "other", "root" } ), first.layers[0] ); + // Children of "other" (position 0) come before children of "root" (position 1) + EXPECT_EQ( QStringList( { "w", "x", "y" } ), first.layers[1] ); +} diff --git a/ApplicationLibCode/UnitTests/TestData/RimWorkflowDefinition/catalog.json b/ApplicationLibCode/UnitTests/TestData/RimWorkflowDefinition/catalog.json new file mode 100644 index 0000000000..14a76bf7e4 --- /dev/null +++ b/ApplicationLibCode/UnitTests/TestData/RimWorkflowDefinition/catalog.json @@ -0,0 +1 @@ +{"format": 1, "status": "ok", "taskmaestro_version": "0.3.0", "tasks": [{"id": "resinsight.add_perforation", "name": "add_perforation", "python_type": "taskmaestro_resinsight.add_perforation.AddPerforation", "description": "Add perforation events to a well path.", "timeout_seconds": null, "input_schema": {"$defs": {"RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}, "WellPath": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, "description": "Input for AddPerforation: RipsInstance + well from upstream, MD range from config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "well_path": {"$ref": "#/$defs/WellPath"}, "event_date": {"description": "Perforation event date", "format": "date", "title": "Event Date", "type": "string"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["resinsight", "well_path", "event_date", "start_md", "end_md"], "title": "AddPerforationInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.AddPerforationInput"], "x-ri-python-type": "taskmaestro_resinsight.models.AddPerforationInput"}, "output_schema": {"description": "Output of AddPerforation: perforation interval details.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["value", "start_md", "end_md"], "title": "PerforationOutput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.PerforationOutput", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.PerforationOutput"}}, {"id": "resinsight.close_project", "name": "close_project", "python_type": "taskmaestro_resinsight.close_project.CloseProject", "description": "Close the current project and pass through the ResInsight connection.", "timeout_seconds": null, "input_schema": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}, "output_schema": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, {"id": "resinsight.configure_grid_view", "name": "configure_grid_view", "python_type": "taskmaestro_resinsight.configure_grid_view.ConfigureGridView", "description": "Apply a cell result and time step to an existing grid view.", "timeout_seconds": null, "input_schema": {"$defs": {"GridView": {"description": "Grid view on a loaded Eclipse case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.View", "rips.generated.generated_classes.ViewWindow", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.View", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.View"}}, "required": ["value"], "title": "GridView", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridView", "taskmaestro.object_model.ObjectModel[View]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridView"}}, "description": "Cell result and time step to display in a grid view.", "properties": {"grid_view": {"$ref": "#/$defs/GridView"}, "result_type": {"enum": ["DYNAMIC_NATIVE", "STATIC_NATIVE", "SOURSIMRL", "GENERATED", "INPUT_PROPERTY", "FORMATION_NAMES", "ALLAN_DIAGRAMS", "FLOW_DIAGNOSTICS", "INJECTION_FLOODING"], "title": "Result Type", "type": "string"}, "result_variable": {"minLength": 1, "title": "Result Variable", "type": "string"}, "time_step": {"default": 0, "minimum": 0, "title": "Time Step", "type": "integer"}}, "required": ["grid_view", "result_type", "result_variable"], "title": "ConfigureGridViewInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.ConfigureGridViewInput"], "x-ri-python-type": "taskmaestro_resinsight.models.ConfigureGridViewInput"}, "output_schema": {"description": "Grid view on a loaded Eclipse case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.View", "rips.generated.generated_classes.ViewWindow", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.View", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.View"}}, "required": ["value"], "title": "GridView", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridView", "taskmaestro.object_model.ObjectModel[View]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridView"}}, {"id": "resinsight.connect", "name": "connect_to_resinsight", "python_type": "taskmaestro_resinsight.connect_to_resinsight.ConnectToResInsight", "description": "Connect to a running ResInsight instance.", "timeout_seconds": null, "input_schema": {"description": "Sentinel config for workflows where all root tasks use JobConfiguration.", "properties": {}, "title": "EmptyConfig", "type": "object", "x-ri-python-bases": ["taskmaestro.job.EmptyConfig"], "x-ri-python-type": "taskmaestro.job.EmptyConfig"}, "output_schema": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, {"id": "resinsight.create_grid_view", "name": "create_grid_view", "python_type": "taskmaestro_resinsight.create_grid_view.CreateGridView", "description": "Create a new grid view on the loaded Eclipse case.", "timeout_seconds": null, "input_schema": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "output_schema": {"description": "Grid view on a loaded Eclipse case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.View", "rips.generated.generated_classes.ViewWindow", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.View", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.View"}}, "required": ["value"], "title": "GridView", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridView", "taskmaestro.object_model.ObjectModel[View]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridView"}}, {"id": "resinsight.exit", "name": "exit_resinsight", "python_type": "taskmaestro_resinsight.exit_resinsight.ExitResInsight", "description": "Send the exit command to a ResInsight application instance.", "timeout_seconds": null, "input_schema": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}, "output_schema": {"description": "Confirmation that the ResInsight exit command was sent.", "properties": {"exited": {"title": "Exited", "type": "boolean"}}, "required": ["exited"], "title": "ExitResult", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.ExitResult"], "x-ri-python-type": "taskmaestro_resinsight.models.ExitResult"}}, {"id": "resinsight.export_completions", "name": "export_completions", "python_type": "taskmaestro_resinsight.export_completions.ExportCompletions", "description": "Fan-in: merge case and perforations, then export completions.\n\nUses inline Inputs/Outputs inner classes for the named-ports fan-in pattern.\nexport_path comes from config.", "timeout_seconds": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "PerforationOutput": {"description": "Output of AddPerforation: perforation interval details.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["value", "start_md", "end_md"], "title": "PerforationOutput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.PerforationOutput", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.PerforationOutput"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "grid_case": {"$ref": "#/$defs/GridCase"}, "perforation_1": {"$ref": "#/$defs/PerforationOutput"}, "perforation_2": {"$ref": "#/$defs/PerforationOutput"}, "event_date": {"description": "Perforation event date", "format": "date", "title": "Event Date", "type": "string"}, "export_path": {"description": "Path to write the completions file", "format": "path", "title": "Export Path", "type": "string"}}, "required": ["resinsight", "grid_case", "perforation_1", "perforation_2", "event_date", "export_path"], "title": "Inputs", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.export_completions.ExportCompletions.Inputs"], "x-ri-python-type": "taskmaestro_resinsight.export_completions.ExportCompletions.Inputs"}, "output_schema": {"properties": {"export_file": {"title": "Export File", "type": "string"}, "well_path_names": {"items": {"type": "string"}, "title": "Well Path Names", "type": "array"}}, "required": ["export_file", "well_path_names"], "title": "Outputs", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.export_completions.ExportCompletions.Outputs"], "x-ri-python-type": "taskmaestro_resinsight.export_completions.ExportCompletions.Outputs"}}, {"id": "resinsight.import_grid_property", "name": "import_grid_property", "python_type": "taskmaestro_resinsight.import_grid_property.ImportGridProperty", "description": "Import grid property files into a loaded grid case.", "timeout_seconds": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "description": "Input for ImportGridProperty: RipsInstance, case, and property paths.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "grid_case": {"$ref": "#/$defs/GridCase"}, "paths": {"description": "Paths to grid property files to load", "items": {"type": "string"}, "title": "Paths", "type": "array"}}, "required": ["resinsight", "grid_case", "paths"], "title": "ImportGridPropertyInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.ImportGridPropertyInput"], "x-ri-python-type": "taskmaestro_resinsight.models.ImportGridPropertyInput"}, "output_schema": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}}, {"id": "resinsight.launch", "name": "launch_resinsight", "python_type": "taskmaestro_resinsight.launch_resinsight.LaunchResInsight", "description": "Launch a new ResInsight instance.", "timeout_seconds": null, "input_schema": {"description": "Configuration for launching ResInsight.", "properties": {"resinsight_executable": {"default": "", "title": "Resinsight Executable", "type": "string"}, "console": {"default": true, "title": "Console", "type": "boolean"}}, "title": "LaunchResInsightInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.launch_resinsight.LaunchResInsightInput"], "x-ri-python-type": "taskmaestro_resinsight.launch_resinsight.LaunchResInsightInput"}, "output_schema": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, {"id": "resinsight.load_model", "name": "load_model", "python_type": "taskmaestro_resinsight.load_model.LoadModel", "description": "Open an Eclipse case from a file path and wrap it as a GridCase.", "timeout_seconds": null, "input_schema": {"$defs": {"RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "description": "Input for LoadModel: RipsInstance from upstream, path from config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "path": {"description": "Path to the .EGRID file to load", "title": "Path", "type": "string"}}, "required": ["resinsight", "path"], "title": "LoadModelInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.LoadModelInput"], "x-ri-python-type": "taskmaestro_resinsight.models.LoadModelInput"}, "output_schema": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}}, {"id": "resinsight.load_regular_surface", "name": "load_regular_surface", "python_type": "taskmaestro_resinsight.load_regular_surface.LoadRegularSurface", "description": "Import a regular surface file and wrap it as a RegularSurface.", "timeout_seconds": null, "input_schema": {"$defs": {"RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "description": "Input for LoadRegularSurface: RipsInstance + path from config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "path": {"description": "Path to the regular surface file to load", "title": "Path", "type": "string"}}, "required": ["resinsight", "path"], "title": "LoadRegularSurfaceInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.LoadRegularSurfaceInput"], "x-ri-python-type": "taskmaestro_resinsight.models.LoadRegularSurfaceInput"}, "output_schema": {"description": "Loaded regular surface file.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Surface", "rips.generated.generated_classes.SurfaceInterface", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Surface", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Surface"}}, "required": ["value"], "title": "RegularSurface", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RegularSurface", "taskmaestro.object_model.ObjectModel[Surface]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RegularSurface"}}, {"id": "resinsight.load_well_path", "name": "load_well_path", "python_type": "taskmaestro_resinsight.load_well_path.LoadWellPath", "description": "Import a well trajectory file (e.g. .dev) and wrap it as a WellPath.", "timeout_seconds": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "description": "Input for LoadWellPath: RipsInstance + grid_case from upstream, path from config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "grid_case": {"$ref": "#/$defs/GridCase"}, "path": {"description": "Path to the .dev well-trajectory file to load", "title": "Path", "type": "string"}}, "required": ["resinsight", "grid_case", "path"], "title": "LoadWellPathInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.LoadWellPathInput"], "x-ri-python-type": "taskmaestro_resinsight.models.LoadWellPathInput"}, "output_schema": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, {"id": "resinsight.open_project", "name": "open_project", "python_type": "taskmaestro_resinsight.open_project.OpenProject", "description": "Open a project file and pass through the ResInsight connection.", "timeout_seconds": null, "input_schema": {"$defs": {"RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "description": "Input for opening a ResInsight project file.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "path": {"description": "Path to the .rsp project file", "minLength": 1, "title": "Path", "type": "string"}}, "required": ["resinsight", "path"], "title": "OpenProjectInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.OpenProjectInput"], "x-ri-python-type": "taskmaestro_resinsight.models.OpenProjectInput"}, "output_schema": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, {"id": "resinsight.save_project", "name": "save_project", "python_type": "taskmaestro_resinsight.save_project.SaveProject", "description": "Save the current project and pass through the ResInsight connection.", "timeout_seconds": null, "input_schema": {"$defs": {"RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "description": "Input for saving the current ResInsight project.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "path": {"default": "", "description": "Destination .rsp path; empty saves to the current project file", "title": "Path", "type": "string"}}, "required": ["resinsight"], "title": "SaveProjectInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.SaveProjectInput"], "x-ri-python-type": "taskmaestro_resinsight.models.SaveProjectInput"}, "output_schema": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, {"id": "resinsight.select_eclipse_case", "name": "select_eclipse_case", "python_type": "taskmaestro_resinsight.select_eclipse_case.SelectEclipseCase", "description": "Pass an existing case through to downstream tasks.", "timeout_seconds": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "description": "Input for SelectEclipseCase: RipsInstance from upstream, case selected via config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "case": {"$ref": "#/$defs/GridCase", "description": "Case to use"}}, "required": ["resinsight", "case"], "title": "SelectEclipseCaseInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.SelectEclipseCaseInput"], "x-ri-python-type": "taskmaestro_resinsight.models.SelectEclipseCaseInput"}, "output_schema": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}}, {"id": "resinsight.select_well_path", "name": "select_well_path", "python_type": "taskmaestro_resinsight.select_well_path.SelectWellPath", "description": "Pass an existing well path through to downstream tasks.", "timeout_seconds": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}, "WellPath": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, "description": "Input for SelectWellPath: RipsInstance from upstream, well path selected via config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "grid_case": {"$ref": "#/$defs/GridCase"}, "well_path": {"$ref": "#/$defs/WellPath", "description": "Well path to use"}}, "required": ["resinsight", "grid_case", "well_path"], "title": "SelectWellPathInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.SelectWellPathInput"], "x-ri-python-type": "taskmaestro_resinsight.models.SelectWellPathInput"}, "output_schema": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}], "workflows": [{"id": "resinsight.completions", "name": "resinsight_completions", "definition": {"format": 1, "name": "resinsight_completions", "result_task": null, "editable": true, "readonly_reasons": [], "warnings": [], "source": {"workflow_yaml": null, "input_yaml": null, "registered_id": "resinsight.completions"}, "header_comment": "", "passthrough": {}, "nodes": [{"name": "connect_to_resinsight", "task": "resinsight.connect", "config_fields": []}, {"name": "select_eclipse_case", "task": "resinsight.select_eclipse_case", "config_fields": ["case"]}, {"name": "select_well_path_1", "task": "resinsight.select_well_path", "config_fields": ["well_path"]}, {"name": "add_perf_1", "task": "resinsight.add_perforation", "config_fields": ["end_md", "event_date", "start_md"]}, {"name": "select_well_path_2", "task": "resinsight.select_well_path", "config_fields": ["well_path"]}, {"name": "add_perf_2", "task": "resinsight.add_perforation", "config_fields": ["end_md", "event_date", "start_md"]}, {"name": "export_completions", "task": "resinsight.export_completions", "config_fields": ["event_date", "export_path"]}], "edges": [{"from": "connect_to_resinsight", "output": null, "to": "select_eclipse_case", "input": "resinsight"}, {"from": "connect_to_resinsight", "output": null, "to": "select_well_path_1", "input": "resinsight"}, {"from": "select_eclipse_case", "output": null, "to": "select_well_path_1", "input": "grid_case"}, {"from": "connect_to_resinsight", "output": null, "to": "add_perf_1", "input": "resinsight"}, {"from": "select_well_path_1", "output": null, "to": "add_perf_1", "input": "well_path"}, {"from": "connect_to_resinsight", "output": null, "to": "select_well_path_2", "input": "resinsight"}, {"from": "select_eclipse_case", "output": null, "to": "select_well_path_2", "input": "grid_case"}, {"from": "connect_to_resinsight", "output": null, "to": "add_perf_2", "input": "resinsight"}, {"from": "select_well_path_2", "output": null, "to": "add_perf_2", "input": "well_path"}, {"from": "connect_to_resinsight", "output": null, "to": "export_completions", "input": "resinsight"}, {"from": "select_eclipse_case", "output": null, "to": "export_completions", "input": "grid_case"}, {"from": "add_perf_1", "output": null, "to": "export_completions", "input": "perforation_1"}, {"from": "add_perf_2", "output": null, "to": "export_completions", "input": "perforation_2"}], "inputs": {"connect_to_resinsight": {}, "select_eclipse_case": {}, "select_well_path_1": {}, "add_perf_1": {}, "select_well_path_2": {}, "add_perf_2": {}, "export_completions": {}}, "task_types": {"resinsight.connect": {"id": "resinsight.connect", "name": "connect_to_resinsight", "python_type": "taskmaestro_resinsight.connect_to_resinsight.ConnectToResInsight", "description": "Connect to a running ResInsight instance.", "timeout_seconds": null, "input_schema": {"description": "Sentinel config for workflows where all root tasks use JobConfiguration.", "properties": {}, "title": "EmptyConfig", "type": "object", "x-ri-python-bases": ["taskmaestro.job.EmptyConfig"], "x-ri-python-type": "taskmaestro.job.EmptyConfig"}, "output_schema": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "resinsight.select_eclipse_case": {"id": "resinsight.select_eclipse_case", "name": "select_eclipse_case", "python_type": "taskmaestro_resinsight.select_eclipse_case.SelectEclipseCase", "description": "Pass an existing case through to downstream tasks.", "timeout_seconds": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "description": "Input for SelectEclipseCase: RipsInstance from upstream, case selected via config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "case": {"$ref": "#/$defs/GridCase", "description": "Case to use"}}, "required": ["resinsight", "case"], "title": "SelectEclipseCaseInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.SelectEclipseCaseInput"], "x-ri-python-type": "taskmaestro_resinsight.models.SelectEclipseCaseInput"}, "output_schema": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}}, "resinsight.select_well_path": {"id": "resinsight.select_well_path", "name": "select_well_path", "python_type": "taskmaestro_resinsight.select_well_path.SelectWellPath", "description": "Pass an existing well path through to downstream tasks.", "timeout_seconds": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}, "WellPath": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, "description": "Input for SelectWellPath: RipsInstance from upstream, well path selected via config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "grid_case": {"$ref": "#/$defs/GridCase"}, "well_path": {"$ref": "#/$defs/WellPath", "description": "Well path to use"}}, "required": ["resinsight", "grid_case", "well_path"], "title": "SelectWellPathInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.SelectWellPathInput"], "x-ri-python-type": "taskmaestro_resinsight.models.SelectWellPathInput"}, "output_schema": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, "resinsight.add_perforation": {"id": "resinsight.add_perforation", "name": "add_perforation", "python_type": "taskmaestro_resinsight.add_perforation.AddPerforation", "description": "Add perforation events to a well path.", "timeout_seconds": null, "input_schema": {"$defs": {"RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}, "WellPath": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, "description": "Input for AddPerforation: RipsInstance + well from upstream, MD range from config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "well_path": {"$ref": "#/$defs/WellPath"}, "event_date": {"description": "Perforation event date", "format": "date", "title": "Event Date", "type": "string"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["resinsight", "well_path", "event_date", "start_md", "end_md"], "title": "AddPerforationInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.AddPerforationInput"], "x-ri-python-type": "taskmaestro_resinsight.models.AddPerforationInput"}, "output_schema": {"description": "Output of AddPerforation: perforation interval details.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["value", "start_md", "end_md"], "title": "PerforationOutput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.PerforationOutput", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.PerforationOutput"}}, "resinsight.export_completions": {"id": "resinsight.export_completions", "name": "export_completions", "python_type": "taskmaestro_resinsight.export_completions.ExportCompletions", "description": "Fan-in: merge case and perforations, then export completions.\n\nUses inline Inputs/Outputs inner classes for the named-ports fan-in pattern.\nexport_path comes from config.", "timeout_seconds": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "PerforationOutput": {"description": "Output of AddPerforation: perforation interval details.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["value", "start_md", "end_md"], "title": "PerforationOutput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.PerforationOutput", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.PerforationOutput"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "grid_case": {"$ref": "#/$defs/GridCase"}, "perforation_1": {"$ref": "#/$defs/PerforationOutput"}, "perforation_2": {"$ref": "#/$defs/PerforationOutput"}, "event_date": {"description": "Perforation event date", "format": "date", "title": "Event Date", "type": "string"}, "export_path": {"description": "Path to write the completions file", "format": "path", "title": "Export Path", "type": "string"}}, "required": ["resinsight", "grid_case", "perforation_1", "perforation_2", "event_date", "export_path"], "title": "Inputs", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.export_completions.ExportCompletions.Inputs"], "x-ri-python-type": "taskmaestro_resinsight.export_completions.ExportCompletions.Inputs"}, "output_schema": {"properties": {"export_file": {"title": "Export File", "type": "string"}, "well_path_names": {"items": {"type": "string"}, "title": "Well Path Names", "type": "array"}}, "required": ["export_file", "well_path_names"], "title": "Outputs", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.export_completions.ExportCompletions.Outputs"], "x-ri-python-type": "taskmaestro_resinsight.export_completions.ExportCompletions.Outputs"}}}, "describe": {"workflow": "resinsight_completions", "result_task": "export_completions", "tasks": [{"name": "connect_to_resinsight", "python_type": "taskmaestro_resinsight.connect_to_resinsight.ConnectToResInsight", "depends_on": null, "config_fields": [], "provided_config_fields": null, "missing_config_fields": null, "config_values": null, "required_input_fields": [], "map": null, "input_schema": {"description": "Sentinel config for workflows where all root tasks use JobConfiguration.", "properties": {}, "title": "EmptyConfig", "type": "object", "x-ri-python-bases": ["taskmaestro.job.EmptyConfig"], "x-ri-python-type": "taskmaestro.job.EmptyConfig"}, "output_schema": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, {"name": "select_eclipse_case", "python_type": "taskmaestro_resinsight.select_eclipse_case.SelectEclipseCase", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}}, "config_fields": ["case"], "provided_config_fields": null, "missing_config_fields": null, "config_values": null, "required_input_fields": ["case", "resinsight"], "map": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "description": "Input for SelectEclipseCase: RipsInstance from upstream, case selected via config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "case": {"$ref": "#/$defs/GridCase", "description": "Case to use"}}, "required": ["resinsight", "case"], "title": "SelectEclipseCaseInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.SelectEclipseCaseInput"], "x-ri-python-type": "taskmaestro_resinsight.models.SelectEclipseCaseInput"}, "output_schema": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}}, {"name": "select_well_path_1", "python_type": "taskmaestro_resinsight.select_well_path.SelectWellPath", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}, "grid_case": {"task": "select_eclipse_case", "field": null}}, "config_fields": ["well_path"], "provided_config_fields": null, "missing_config_fields": null, "config_values": null, "required_input_fields": ["grid_case", "resinsight", "well_path"], "map": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}, "WellPath": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, "description": "Input for SelectWellPath: RipsInstance from upstream, well path selected via config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "grid_case": {"$ref": "#/$defs/GridCase"}, "well_path": {"$ref": "#/$defs/WellPath", "description": "Well path to use"}}, "required": ["resinsight", "grid_case", "well_path"], "title": "SelectWellPathInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.SelectWellPathInput"], "x-ri-python-type": "taskmaestro_resinsight.models.SelectWellPathInput"}, "output_schema": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, {"name": "add_perf_1", "python_type": "taskmaestro_resinsight.add_perforation.AddPerforation", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}, "well_path": {"task": "select_well_path_1", "field": null}}, "config_fields": ["end_md", "event_date", "start_md"], "provided_config_fields": null, "missing_config_fields": null, "config_values": null, "required_input_fields": ["end_md", "event_date", "resinsight", "start_md", "well_path"], "map": null, "input_schema": {"$defs": {"RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}, "WellPath": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, "description": "Input for AddPerforation: RipsInstance + well from upstream, MD range from config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "well_path": {"$ref": "#/$defs/WellPath"}, "event_date": {"description": "Perforation event date", "format": "date", "title": "Event Date", "type": "string"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["resinsight", "well_path", "event_date", "start_md", "end_md"], "title": "AddPerforationInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.AddPerforationInput"], "x-ri-python-type": "taskmaestro_resinsight.models.AddPerforationInput"}, "output_schema": {"description": "Output of AddPerforation: perforation interval details.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["value", "start_md", "end_md"], "title": "PerforationOutput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.PerforationOutput", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.PerforationOutput"}}, {"name": "select_well_path_2", "python_type": "taskmaestro_resinsight.select_well_path.SelectWellPath", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}, "grid_case": {"task": "select_eclipse_case", "field": null}}, "config_fields": ["well_path"], "provided_config_fields": null, "missing_config_fields": null, "config_values": null, "required_input_fields": ["grid_case", "resinsight", "well_path"], "map": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}, "WellPath": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, "description": "Input for SelectWellPath: RipsInstance from upstream, well path selected via config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "grid_case": {"$ref": "#/$defs/GridCase"}, "well_path": {"$ref": "#/$defs/WellPath", "description": "Well path to use"}}, "required": ["resinsight", "grid_case", "well_path"], "title": "SelectWellPathInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.SelectWellPathInput"], "x-ri-python-type": "taskmaestro_resinsight.models.SelectWellPathInput"}, "output_schema": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, {"name": "add_perf_2", "python_type": "taskmaestro_resinsight.add_perforation.AddPerforation", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}, "well_path": {"task": "select_well_path_2", "field": null}}, "config_fields": ["end_md", "event_date", "start_md"], "provided_config_fields": null, "missing_config_fields": null, "config_values": null, "required_input_fields": ["end_md", "event_date", "resinsight", "start_md", "well_path"], "map": null, "input_schema": {"$defs": {"RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}, "WellPath": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, "description": "Input for AddPerforation: RipsInstance + well from upstream, MD range from config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "well_path": {"$ref": "#/$defs/WellPath"}, "event_date": {"description": "Perforation event date", "format": "date", "title": "Event Date", "type": "string"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["resinsight", "well_path", "event_date", "start_md", "end_md"], "title": "AddPerforationInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.AddPerforationInput"], "x-ri-python-type": "taskmaestro_resinsight.models.AddPerforationInput"}, "output_schema": {"description": "Output of AddPerforation: perforation interval details.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["value", "start_md", "end_md"], "title": "PerforationOutput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.PerforationOutput", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.PerforationOutput"}}, {"name": "export_completions", "python_type": "taskmaestro_resinsight.export_completions.ExportCompletions", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}, "grid_case": {"task": "select_eclipse_case", "field": null}, "perforation_1": {"task": "add_perf_1", "field": null}, "perforation_2": {"task": "add_perf_2", "field": null}}, "config_fields": ["event_date", "export_path"], "provided_config_fields": null, "missing_config_fields": null, "config_values": null, "required_input_fields": ["event_date", "export_path", "grid_case", "perforation_1", "perforation_2", "resinsight"], "map": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "PerforationOutput": {"description": "Output of AddPerforation: perforation interval details.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["value", "start_md", "end_md"], "title": "PerforationOutput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.PerforationOutput", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.PerforationOutput"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "grid_case": {"$ref": "#/$defs/GridCase"}, "perforation_1": {"$ref": "#/$defs/PerforationOutput"}, "perforation_2": {"$ref": "#/$defs/PerforationOutput"}, "event_date": {"description": "Perforation event date", "format": "date", "title": "Event Date", "type": "string"}, "export_path": {"description": "Path to write the completions file", "format": "path", "title": "Export Path", "type": "string"}}, "required": ["resinsight", "grid_case", "perforation_1", "perforation_2", "event_date", "export_path"], "title": "Inputs", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.export_completions.ExportCompletions.Inputs"], "x-ri-python-type": "taskmaestro_resinsight.export_completions.ExportCompletions.Inputs"}, "output_schema": {"properties": {"export_file": {"title": "Export File", "type": "string"}, "well_path_names": {"items": {"type": "string"}, "title": "Well Path Names", "type": "array"}}, "required": ["export_file", "well_path_names"], "title": "Outputs", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.export_completions.ExportCompletions.Outputs"], "x-ri-python-type": "taskmaestro_resinsight.export_completions.ExportCompletions.Outputs"}}]}, "describe_error": null}, "describe": {"workflow": "resinsight_completions", "result_task": "export_completions", "tasks": [{"name": "connect_to_resinsight", "python_type": "taskmaestro_resinsight.connect_to_resinsight.ConnectToResInsight", "depends_on": null, "config_fields": [], "provided_config_fields": null, "missing_config_fields": null, "config_values": null, "required_input_fields": [], "map": null, "input_schema": {"description": "Sentinel config for workflows where all root tasks use JobConfiguration.", "properties": {}, "title": "EmptyConfig", "type": "object", "x-ri-python-bases": ["taskmaestro.job.EmptyConfig"], "x-ri-python-type": "taskmaestro.job.EmptyConfig"}, "output_schema": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, {"name": "select_eclipse_case", "python_type": "taskmaestro_resinsight.select_eclipse_case.SelectEclipseCase", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}}, "config_fields": ["case"], "provided_config_fields": null, "missing_config_fields": null, "config_values": null, "required_input_fields": ["case", "resinsight"], "map": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "description": "Input for SelectEclipseCase: RipsInstance from upstream, case selected via config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "case": {"$ref": "#/$defs/GridCase", "description": "Case to use"}}, "required": ["resinsight", "case"], "title": "SelectEclipseCaseInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.SelectEclipseCaseInput"], "x-ri-python-type": "taskmaestro_resinsight.models.SelectEclipseCaseInput"}, "output_schema": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}}, {"name": "select_well_path_1", "python_type": "taskmaestro_resinsight.select_well_path.SelectWellPath", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}, "grid_case": {"task": "select_eclipse_case", "field": null}}, "config_fields": ["well_path"], "provided_config_fields": null, "missing_config_fields": null, "config_values": null, "required_input_fields": ["grid_case", "resinsight", "well_path"], "map": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}, "WellPath": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, "description": "Input for SelectWellPath: RipsInstance from upstream, well path selected via config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "grid_case": {"$ref": "#/$defs/GridCase"}, "well_path": {"$ref": "#/$defs/WellPath", "description": "Well path to use"}}, "required": ["resinsight", "grid_case", "well_path"], "title": "SelectWellPathInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.SelectWellPathInput"], "x-ri-python-type": "taskmaestro_resinsight.models.SelectWellPathInput"}, "output_schema": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, {"name": "add_perf_1", "python_type": "taskmaestro_resinsight.add_perforation.AddPerforation", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}, "well_path": {"task": "select_well_path_1", "field": null}}, "config_fields": ["end_md", "event_date", "start_md"], "provided_config_fields": null, "missing_config_fields": null, "config_values": null, "required_input_fields": ["end_md", "event_date", "resinsight", "start_md", "well_path"], "map": null, "input_schema": {"$defs": {"RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}, "WellPath": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, "description": "Input for AddPerforation: RipsInstance + well from upstream, MD range from config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "well_path": {"$ref": "#/$defs/WellPath"}, "event_date": {"description": "Perforation event date", "format": "date", "title": "Event Date", "type": "string"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["resinsight", "well_path", "event_date", "start_md", "end_md"], "title": "AddPerforationInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.AddPerforationInput"], "x-ri-python-type": "taskmaestro_resinsight.models.AddPerforationInput"}, "output_schema": {"description": "Output of AddPerforation: perforation interval details.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["value", "start_md", "end_md"], "title": "PerforationOutput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.PerforationOutput", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.PerforationOutput"}}, {"name": "select_well_path_2", "python_type": "taskmaestro_resinsight.select_well_path.SelectWellPath", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}, "grid_case": {"task": "select_eclipse_case", "field": null}}, "config_fields": ["well_path"], "provided_config_fields": null, "missing_config_fields": null, "config_values": null, "required_input_fields": ["grid_case", "resinsight", "well_path"], "map": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}, "WellPath": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, "description": "Input for SelectWellPath: RipsInstance from upstream, well path selected via config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "grid_case": {"$ref": "#/$defs/GridCase"}, "well_path": {"$ref": "#/$defs/WellPath", "description": "Well path to use"}}, "required": ["resinsight", "grid_case", "well_path"], "title": "SelectWellPathInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.SelectWellPathInput"], "x-ri-python-type": "taskmaestro_resinsight.models.SelectWellPathInput"}, "output_schema": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, {"name": "add_perf_2", "python_type": "taskmaestro_resinsight.add_perforation.AddPerforation", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}, "well_path": {"task": "select_well_path_2", "field": null}}, "config_fields": ["end_md", "event_date", "start_md"], "provided_config_fields": null, "missing_config_fields": null, "config_values": null, "required_input_fields": ["end_md", "event_date", "resinsight", "start_md", "well_path"], "map": null, "input_schema": {"$defs": {"RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}, "WellPath": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, "description": "Input for AddPerforation: RipsInstance + well from upstream, MD range from config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "well_path": {"$ref": "#/$defs/WellPath"}, "event_date": {"description": "Perforation event date", "format": "date", "title": "Event Date", "type": "string"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["resinsight", "well_path", "event_date", "start_md", "end_md"], "title": "AddPerforationInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.AddPerforationInput"], "x-ri-python-type": "taskmaestro_resinsight.models.AddPerforationInput"}, "output_schema": {"description": "Output of AddPerforation: perforation interval details.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["value", "start_md", "end_md"], "title": "PerforationOutput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.PerforationOutput", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.PerforationOutput"}}, {"name": "export_completions", "python_type": "taskmaestro_resinsight.export_completions.ExportCompletions", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}, "grid_case": {"task": "select_eclipse_case", "field": null}, "perforation_1": {"task": "add_perf_1", "field": null}, "perforation_2": {"task": "add_perf_2", "field": null}}, "config_fields": ["event_date", "export_path"], "provided_config_fields": null, "missing_config_fields": null, "config_values": null, "required_input_fields": ["event_date", "export_path", "grid_case", "perforation_1", "perforation_2", "resinsight"], "map": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "PerforationOutput": {"description": "Output of AddPerforation: perforation interval details.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["value", "start_md", "end_md"], "title": "PerforationOutput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.PerforationOutput", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.PerforationOutput"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "grid_case": {"$ref": "#/$defs/GridCase"}, "perforation_1": {"$ref": "#/$defs/PerforationOutput"}, "perforation_2": {"$ref": "#/$defs/PerforationOutput"}, "event_date": {"description": "Perforation event date", "format": "date", "title": "Event Date", "type": "string"}, "export_path": {"description": "Path to write the completions file", "format": "path", "title": "Export Path", "type": "string"}}, "required": ["resinsight", "grid_case", "perforation_1", "perforation_2", "event_date", "export_path"], "title": "Inputs", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.export_completions.ExportCompletions.Inputs"], "x-ri-python-type": "taskmaestro_resinsight.export_completions.ExportCompletions.Inputs"}, "output_schema": {"properties": {"export_file": {"title": "Export File", "type": "string"}, "well_path_names": {"items": {"type": "string"}, "title": "Well Path Names", "type": "array"}}, "required": ["export_file", "well_path_names"], "title": "Outputs", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.export_completions.ExportCompletions.Outputs"], "x-ri-python-type": "taskmaestro_resinsight.export_completions.ExportCompletions.Outputs"}}]}, "editable": true, "readonly_reasons": []}], "errors": []} diff --git a/ApplicationLibCode/UnitTests/TestData/RimWorkflowDefinition/completions.json b/ApplicationLibCode/UnitTests/TestData/RimWorkflowDefinition/completions.json new file mode 100644 index 0000000000..bf419907f9 --- /dev/null +++ b/ApplicationLibCode/UnitTests/TestData/RimWorkflowDefinition/completions.json @@ -0,0 +1 @@ +{"format": 1, "name": "resinsight_completions", "result_task": null, "editable": true, "readonly_reasons": [], "warnings": [], "source": {"workflow_yaml": "test_data/taskmaestro_workflows/resinsight_completions/workflow.yaml", "input_yaml": "test_data/taskmaestro_workflows/resinsight_completions/input.yaml", "registered_id": null}, "header_comment": "# ResInsight Completions Pipeline \u2014 YAML workflow configuration\n#\n# Tasks use stable entry-point identifiers published by taskmaestro-resinsight,\n# rather than Python module/class paths.\n#\n# DAG with fan-out / fan-in pattern:\n# ConnectToResInsight \u2192 SelectEclipseCase \u2192 [SelectWellPath x2] \u2192 [AddPerforation x2] \u2192 ExportCompletions", "passthrough": {"runner": {"hooks": [{"hook": "taskmaestro.hooks.logging.LoggingHook"}, {"hook": "taskmaestro.hooks.timing.TimingHook"}]}}, "nodes": [{"name": "connect_to_resinsight", "task": "resinsight.connect", "config_fields": []}, {"name": "select_eclipse_case", "task": "resinsight.select_eclipse_case", "config_fields": ["case"]}, {"name": "select_well_path_1", "task": "resinsight.select_well_path", "config_fields": ["well_path"]}, {"name": "select_well_path_2", "task": "resinsight.select_well_path", "config_fields": ["well_path"]}, {"name": "add_perf_1", "task": "resinsight.add_perforation", "config_fields": ["event_date", "start_md", "end_md"]}, {"name": "add_perf_2", "task": "resinsight.add_perforation", "config_fields": ["event_date", "start_md", "end_md"]}, {"name": "export_completions", "task": "resinsight.export_completions", "config_fields": ["event_date", "export_path"]}], "edges": [{"from": "connect_to_resinsight", "output": null, "to": "select_eclipse_case", "input": "resinsight"}, {"from": "connect_to_resinsight", "output": null, "to": "select_well_path_1", "input": "resinsight"}, {"from": "select_eclipse_case", "output": null, "to": "select_well_path_1", "input": "grid_case"}, {"from": "connect_to_resinsight", "output": null, "to": "select_well_path_2", "input": "resinsight"}, {"from": "select_eclipse_case", "output": null, "to": "select_well_path_2", "input": "grid_case"}, {"from": "connect_to_resinsight", "output": null, "to": "add_perf_1", "input": "resinsight"}, {"from": "select_well_path_1", "output": null, "to": "add_perf_1", "input": "well_path"}, {"from": "connect_to_resinsight", "output": null, "to": "add_perf_2", "input": "resinsight"}, {"from": "select_well_path_2", "output": null, "to": "add_perf_2", "input": "well_path"}, {"from": "connect_to_resinsight", "output": null, "to": "export_completions", "input": "resinsight"}, {"from": "select_eclipse_case", "output": null, "to": "export_completions", "input": "grid_case"}, {"from": "add_perf_1", "output": null, "to": "export_completions", "input": "perforation_1"}, {"from": "add_perf_2", "output": null, "to": "export_completions", "input": "perforation_2"}], "inputs": {"connect_to_resinsight": {}, "select_eclipse_case": {}, "select_well_path_1": {}, "select_well_path_2": {}, "add_perf_1": {"event_date": "2024-01-01", "start_md": 3000.0, "end_md": 3500.0}, "add_perf_2": {"event_date": "2024-02-01", "start_md": 2400.0, "end_md": 2600.0}, "export_completions": {"event_date": "2024-05-01", "export_path": "/tmp/completions.txt"}}, "task_types": {"resinsight.connect": {"id": "resinsight.connect", "name": "connect_to_resinsight", "python_type": "taskmaestro_resinsight.connect_to_resinsight.ConnectToResInsight", "description": "Connect to a running ResInsight instance.", "timeout_seconds": null, "input_schema": {"description": "Sentinel config for workflows where all root tasks use JobConfiguration.", "properties": {}, "title": "EmptyConfig", "type": "object", "x-ri-python-bases": ["taskmaestro.job.EmptyConfig"], "x-ri-python-type": "taskmaestro.job.EmptyConfig"}, "output_schema": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "resinsight.select_eclipse_case": {"id": "resinsight.select_eclipse_case", "name": "select_eclipse_case", "python_type": "taskmaestro_resinsight.select_eclipse_case.SelectEclipseCase", "description": "Pass an existing case through to downstream tasks.", "timeout_seconds": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "description": "Input for SelectEclipseCase: RipsInstance from upstream, case selected via config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "case": {"$ref": "#/$defs/GridCase", "description": "Case to use"}}, "required": ["resinsight", "case"], "title": "SelectEclipseCaseInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.SelectEclipseCaseInput"], "x-ri-python-type": "taskmaestro_resinsight.models.SelectEclipseCaseInput"}, "output_schema": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}}, "resinsight.select_well_path": {"id": "resinsight.select_well_path", "name": "select_well_path", "python_type": "taskmaestro_resinsight.select_well_path.SelectWellPath", "description": "Pass an existing well path through to downstream tasks.", "timeout_seconds": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}, "WellPath": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, "description": "Input for SelectWellPath: RipsInstance from upstream, well path selected via config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "grid_case": {"$ref": "#/$defs/GridCase"}, "well_path": {"$ref": "#/$defs/WellPath", "description": "Well path to use"}}, "required": ["resinsight", "grid_case", "well_path"], "title": "SelectWellPathInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.SelectWellPathInput"], "x-ri-python-type": "taskmaestro_resinsight.models.SelectWellPathInput"}, "output_schema": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, "resinsight.add_perforation": {"id": "resinsight.add_perforation", "name": "add_perforation", "python_type": "taskmaestro_resinsight.add_perforation.AddPerforation", "description": "Add perforation events to a well path.", "timeout_seconds": null, "input_schema": {"$defs": {"RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}, "WellPath": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, "description": "Input for AddPerforation: RipsInstance + well from upstream, MD range from config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "well_path": {"$ref": "#/$defs/WellPath"}, "event_date": {"description": "Perforation event date", "format": "date", "title": "Event Date", "type": "string"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["resinsight", "well_path", "event_date", "start_md", "end_md"], "title": "AddPerforationInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.AddPerforationInput"], "x-ri-python-type": "taskmaestro_resinsight.models.AddPerforationInput"}, "output_schema": {"description": "Output of AddPerforation: perforation interval details.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["value", "start_md", "end_md"], "title": "PerforationOutput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.PerforationOutput", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.PerforationOutput"}}, "resinsight.export_completions": {"id": "resinsight.export_completions", "name": "export_completions", "python_type": "taskmaestro_resinsight.export_completions.ExportCompletions", "description": "Fan-in: merge case and perforations, then export completions.\n\nUses inline Inputs/Outputs inner classes for the named-ports fan-in pattern.\nexport_path comes from config.", "timeout_seconds": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "PerforationOutput": {"description": "Output of AddPerforation: perforation interval details.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["value", "start_md", "end_md"], "title": "PerforationOutput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.PerforationOutput", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.PerforationOutput"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "grid_case": {"$ref": "#/$defs/GridCase"}, "perforation_1": {"$ref": "#/$defs/PerforationOutput"}, "perforation_2": {"$ref": "#/$defs/PerforationOutput"}, "event_date": {"description": "Perforation event date", "format": "date", "title": "Event Date", "type": "string"}, "export_path": {"description": "Path to write the completions file", "format": "path", "title": "Export Path", "type": "string"}}, "required": ["resinsight", "grid_case", "perforation_1", "perforation_2", "event_date", "export_path"], "title": "Inputs", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.export_completions.ExportCompletions.Inputs"], "x-ri-python-type": "taskmaestro_resinsight.export_completions.ExportCompletions.Inputs"}, "output_schema": {"properties": {"export_file": {"title": "Export File", "type": "string"}, "well_path_names": {"items": {"type": "string"}, "title": "Well Path Names", "type": "array"}}, "required": ["export_file", "well_path_names"], "title": "Outputs", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.export_completions.ExportCompletions.Outputs"], "x-ri-python-type": "taskmaestro_resinsight.export_completions.ExportCompletions.Outputs"}}}, "describe": {"workflow": "resinsight_completions", "result_task": "export_completions", "tasks": [{"name": "connect_to_resinsight", "python_type": "taskmaestro_resinsight.connect_to_resinsight.ConnectToResInsight", "depends_on": null, "config_fields": [], "provided_config_fields": [], "missing_config_fields": [], "config_values": {}, "required_input_fields": [], "map": null, "input_schema": {"description": "Sentinel config for workflows where all root tasks use JobConfiguration.", "properties": {}, "title": "EmptyConfig", "type": "object"}, "output_schema": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object"}}, {"name": "select_eclipse_case", "python_type": "taskmaestro_resinsight.select_eclipse_case.SelectEclipseCase", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}}, "config_fields": ["case"], "provided_config_fields": [], "missing_config_fields": ["case"], "config_values": {}, "required_input_fields": ["case", "resinsight"], "map": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object"}}, "description": "Input for SelectEclipseCase: RipsInstance from upstream, case selected via config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "case": {"$ref": "#/$defs/GridCase", "description": "Case to use"}}, "required": ["resinsight", "case"], "title": "SelectEclipseCaseInput", "type": "object"}, "output_schema": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object"}}, {"name": "select_well_path_1", "python_type": "taskmaestro_resinsight.select_well_path.SelectWellPath", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}, "grid_case": {"task": "select_eclipse_case", "field": null}}, "config_fields": ["well_path"], "provided_config_fields": [], "missing_config_fields": ["well_path"], "config_values": {}, "required_input_fields": ["grid_case", "resinsight", "well_path"], "map": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object"}, "WellPath": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object"}}, "description": "Input for SelectWellPath: RipsInstance from upstream, well path selected via config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "grid_case": {"$ref": "#/$defs/GridCase"}, "well_path": {"$ref": "#/$defs/WellPath", "description": "Well path to use"}}, "required": ["resinsight", "grid_case", "well_path"], "title": "SelectWellPathInput", "type": "object"}, "output_schema": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object"}}, {"name": "add_perf_1", "python_type": "taskmaestro_resinsight.add_perforation.AddPerforation", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}, "well_path": {"task": "select_well_path_1", "field": null}}, "config_fields": ["end_md", "event_date", "start_md"], "provided_config_fields": ["end_md", "event_date", "start_md"], "missing_config_fields": [], "config_values": {"end_md": 3500.0, "event_date": "2024-01-01", "start_md": 3000.0}, "required_input_fields": ["end_md", "event_date", "resinsight", "start_md", "well_path"], "map": null, "input_schema": {"$defs": {"RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object"}, "WellPath": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object"}}, "description": "Input for AddPerforation: RipsInstance + well from upstream, MD range from config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "well_path": {"$ref": "#/$defs/WellPath"}, "event_date": {"description": "Perforation event date", "format": "date", "title": "Event Date", "type": "string"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["resinsight", "well_path", "event_date", "start_md", "end_md"], "title": "AddPerforationInput", "type": "object"}, "output_schema": {"description": "Output of AddPerforation: perforation interval details.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["value", "start_md", "end_md"], "title": "PerforationOutput", "type": "object"}}, {"name": "select_well_path_2", "python_type": "taskmaestro_resinsight.select_well_path.SelectWellPath", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}, "grid_case": {"task": "select_eclipse_case", "field": null}}, "config_fields": ["well_path"], "provided_config_fields": [], "missing_config_fields": ["well_path"], "config_values": {}, "required_input_fields": ["grid_case", "resinsight", "well_path"], "map": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object"}, "WellPath": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object"}}, "description": "Input for SelectWellPath: RipsInstance from upstream, well path selected via config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "grid_case": {"$ref": "#/$defs/GridCase"}, "well_path": {"$ref": "#/$defs/WellPath", "description": "Well path to use"}}, "required": ["resinsight", "grid_case", "well_path"], "title": "SelectWellPathInput", "type": "object"}, "output_schema": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object"}}, {"name": "add_perf_2", "python_type": "taskmaestro_resinsight.add_perforation.AddPerforation", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}, "well_path": {"task": "select_well_path_2", "field": null}}, "config_fields": ["end_md", "event_date", "start_md"], "provided_config_fields": ["end_md", "event_date", "start_md"], "missing_config_fields": [], "config_values": {"end_md": 2600.0, "event_date": "2024-02-01", "start_md": 2400.0}, "required_input_fields": ["end_md", "event_date", "resinsight", "start_md", "well_path"], "map": null, "input_schema": {"$defs": {"RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object"}, "WellPath": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object"}}, "description": "Input for AddPerforation: RipsInstance + well from upstream, MD range from config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "well_path": {"$ref": "#/$defs/WellPath"}, "event_date": {"description": "Perforation event date", "format": "date", "title": "Event Date", "type": "string"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["resinsight", "well_path", "event_date", "start_md", "end_md"], "title": "AddPerforationInput", "type": "object"}, "output_schema": {"description": "Output of AddPerforation: perforation interval details.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["value", "start_md", "end_md"], "title": "PerforationOutput", "type": "object"}}, {"name": "export_completions", "python_type": "taskmaestro_resinsight.export_completions.ExportCompletions", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}, "grid_case": {"task": "select_eclipse_case", "field": null}, "perforation_1": {"task": "add_perf_1", "field": null}, "perforation_2": {"task": "add_perf_2", "field": null}}, "config_fields": ["event_date", "export_path"], "provided_config_fields": ["event_date", "export_path"], "missing_config_fields": [], "config_values": {"event_date": "2024-05-01", "export_path": "/tmp/completions.txt"}, "required_input_fields": ["event_date", "export_path", "grid_case", "perforation_1", "perforation_2", "resinsight"], "map": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object"}, "PerforationOutput": {"description": "Output of AddPerforation: perforation interval details.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["value", "start_md", "end_md"], "title": "PerforationOutput", "type": "object"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object"}}, "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "grid_case": {"$ref": "#/$defs/GridCase"}, "perforation_1": {"$ref": "#/$defs/PerforationOutput"}, "perforation_2": {"$ref": "#/$defs/PerforationOutput"}, "event_date": {"description": "Perforation event date", "format": "date", "title": "Event Date", "type": "string"}, "export_path": {"description": "Path to write the completions file", "format": "path", "title": "Export Path", "type": "string"}}, "required": ["resinsight", "grid_case", "perforation_1", "perforation_2", "event_date", "export_path"], "title": "Inputs", "type": "object"}, "output_schema": {"properties": {"export_file": {"title": "Export File", "type": "string"}, "well_path_names": {"items": {"type": "string"}, "title": "Well Path Names", "type": "array"}}, "required": ["export_file", "well_path_names"], "title": "Outputs", "type": "object"}}]}, "describe_error": null, "status": "ok"} diff --git a/ApplicationLibCode/UnitTests/TestData/RimWorkflowDefinition/load_data.json b/ApplicationLibCode/UnitTests/TestData/RimWorkflowDefinition/load_data.json new file mode 100644 index 0000000000..8771e14716 --- /dev/null +++ b/ApplicationLibCode/UnitTests/TestData/RimWorkflowDefinition/load_data.json @@ -0,0 +1 @@ +{"format": 1, "name": "resinsight_load_data", "result_task": "import_grid_property", "editable": true, "readonly_reasons": [], "warnings": [], "source": {"workflow_yaml": "test_data/taskmaestro_workflows/resinsight_load_data/workflow.yaml", "input_yaml": "test_data/taskmaestro_workflows/resinsight_load_data/input.yaml", "registered_id": null}, "header_comment": "# ResInsight grid, property, and regular surface loading example.", "passthrough": {}, "nodes": [{"name": "connect_to_resinsight", "task": "resinsight.connect", "config_fields": []}, {"name": "load_model", "task": "resinsight.load_model", "config_fields": ["path"]}, {"name": "import_grid_property", "task": "resinsight.import_grid_property", "config_fields": ["paths"]}, {"name": "load_regular_surface", "task": "resinsight.load_regular_surface", "config_fields": ["path"]}], "edges": [{"from": "connect_to_resinsight", "output": null, "to": "load_model", "input": "resinsight"}, {"from": "connect_to_resinsight", "output": null, "to": "import_grid_property", "input": "resinsight"}, {"from": "load_model", "output": null, "to": "import_grid_property", "input": "grid_case"}, {"from": "connect_to_resinsight", "output": null, "to": "load_regular_surface", "input": "resinsight"}], "inputs": {"connect_to_resinsight": {}, "load_model": {"path": "/path/to/grid.roff"}, "import_grid_property": {"paths": ["/path/to/property.roffasc"]}, "load_regular_surface": {"path": "/path/to/surface.irap"}}, "task_types": {"resinsight.connect": {"id": "resinsight.connect", "name": "connect_to_resinsight", "python_type": "taskmaestro_resinsight.connect_to_resinsight.ConnectToResInsight", "description": "Connect to a running ResInsight instance.", "timeout_seconds": null, "input_schema": {"description": "Sentinel config for workflows where all root tasks use JobConfiguration.", "properties": {}, "title": "EmptyConfig", "type": "object", "x-ri-python-bases": ["taskmaestro.job.EmptyConfig"], "x-ri-python-type": "taskmaestro.job.EmptyConfig"}, "output_schema": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "resinsight.load_model": {"id": "resinsight.load_model", "name": "load_model", "python_type": "taskmaestro_resinsight.load_model.LoadModel", "description": "Open an Eclipse case from a file path and wrap it as a GridCase.", "timeout_seconds": null, "input_schema": {"$defs": {"RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "description": "Input for LoadModel: RipsInstance from upstream, path from config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "path": {"description": "Path to the .EGRID file to load", "title": "Path", "type": "string"}}, "required": ["resinsight", "path"], "title": "LoadModelInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.LoadModelInput"], "x-ri-python-type": "taskmaestro_resinsight.models.LoadModelInput"}, "output_schema": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}}, "resinsight.import_grid_property": {"id": "resinsight.import_grid_property", "name": "import_grid_property", "python_type": "taskmaestro_resinsight.import_grid_property.ImportGridProperty", "description": "Import grid property files into a loaded grid case.", "timeout_seconds": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "description": "Input for ImportGridProperty: RipsInstance, case, and property paths.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "grid_case": {"$ref": "#/$defs/GridCase"}, "paths": {"description": "Paths to grid property files to load", "items": {"type": "string"}, "title": "Paths", "type": "array"}}, "required": ["resinsight", "grid_case", "paths"], "title": "ImportGridPropertyInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.ImportGridPropertyInput"], "x-ri-python-type": "taskmaestro_resinsight.models.ImportGridPropertyInput"}, "output_schema": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}}, "resinsight.load_regular_surface": {"id": "resinsight.load_regular_surface", "name": "load_regular_surface", "python_type": "taskmaestro_resinsight.load_regular_surface.LoadRegularSurface", "description": "Import a regular surface file and wrap it as a RegularSurface.", "timeout_seconds": null, "input_schema": {"$defs": {"RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "description": "Input for LoadRegularSurface: RipsInstance + path from config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "path": {"description": "Path to the regular surface file to load", "title": "Path", "type": "string"}}, "required": ["resinsight", "path"], "title": "LoadRegularSurfaceInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.LoadRegularSurfaceInput"], "x-ri-python-type": "taskmaestro_resinsight.models.LoadRegularSurfaceInput"}, "output_schema": {"description": "Loaded regular surface file.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Surface", "rips.generated.generated_classes.SurfaceInterface", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Surface", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Surface"}}, "required": ["value"], "title": "RegularSurface", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RegularSurface", "taskmaestro.object_model.ObjectModel[Surface]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RegularSurface"}}}, "describe": {"workflow": "resinsight_load_data", "result_task": "import_grid_property", "tasks": [{"name": "connect_to_resinsight", "python_type": "taskmaestro_resinsight.connect_to_resinsight.ConnectToResInsight", "depends_on": null, "config_fields": [], "provided_config_fields": [], "missing_config_fields": [], "config_values": {}, "required_input_fields": [], "map": null, "input_schema": {"description": "Sentinel config for workflows where all root tasks use JobConfiguration.", "properties": {}, "title": "EmptyConfig", "type": "object"}, "output_schema": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object"}}, {"name": "load_model", "python_type": "taskmaestro_resinsight.load_model.LoadModel", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}}, "config_fields": ["path"], "provided_config_fields": ["path"], "missing_config_fields": [], "config_values": {"path": "/path/to/grid.roff"}, "required_input_fields": ["path", "resinsight"], "map": null, "input_schema": {"$defs": {"RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object"}}, "description": "Input for LoadModel: RipsInstance from upstream, path from config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "path": {"description": "Path to the .EGRID file to load", "title": "Path", "type": "string"}}, "required": ["resinsight", "path"], "title": "LoadModelInput", "type": "object"}, "output_schema": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object"}}, {"name": "import_grid_property", "python_type": "taskmaestro_resinsight.import_grid_property.ImportGridProperty", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}, "grid_case": {"task": "load_model", "field": null}}, "config_fields": ["paths"], "provided_config_fields": ["paths"], "missing_config_fields": [], "config_values": {"paths": ["/path/to/property.roffasc"]}, "required_input_fields": ["grid_case", "paths", "resinsight"], "map": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object"}}, "description": "Input for ImportGridProperty: RipsInstance, case, and property paths.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "grid_case": {"$ref": "#/$defs/GridCase"}, "paths": {"description": "Paths to grid property files to load", "items": {"type": "string"}, "title": "Paths", "type": "array"}}, "required": ["resinsight", "grid_case", "paths"], "title": "ImportGridPropertyInput", "type": "object"}, "output_schema": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object"}}, {"name": "load_regular_surface", "python_type": "taskmaestro_resinsight.load_regular_surface.LoadRegularSurface", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}}, "config_fields": ["path"], "provided_config_fields": ["path"], "missing_config_fields": [], "config_values": {"path": "/path/to/surface.irap"}, "required_input_fields": ["path", "resinsight"], "map": null, "input_schema": {"$defs": {"RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object"}}, "description": "Input for LoadRegularSurface: RipsInstance + path from config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "path": {"description": "Path to the regular surface file to load", "title": "Path", "type": "string"}}, "required": ["resinsight", "path"], "title": "LoadRegularSurfaceInput", "type": "object"}, "output_schema": {"description": "Loaded regular surface file.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Surface"}}, "required": ["value"], "title": "RegularSurface", "type": "object"}}]}, "describe_error": null, "status": "ok"} diff --git a/ApplicationLibCode/UserInterface/CMakeLists_files.cmake b/ApplicationLibCode/UserInterface/CMakeLists_files.cmake index 4a08c5583a..ad4001bc5b 100644 --- a/ApplicationLibCode/UserInterface/CMakeLists_files.cmake +++ b/ApplicationLibCode/UserInterface/CMakeLists_files.cmake @@ -74,6 +74,10 @@ set(SOURCE_GROUP_SOURCE_FILES ${CMAKE_CURRENT_LIST_DIR}/RiuMessageDialog.cpp ${CMAKE_CURRENT_LIST_DIR}/RiuWorkflowJobRunner.cpp ${CMAKE_CURRENT_LIST_DIR}/RiuWorkflowGraphView.cpp + ${CMAKE_CURRENT_LIST_DIR}/RiuWorkflowGraphLayout.cpp + ${CMAKE_CURRENT_LIST_DIR}/RiuWorkflowTaskPalette.cpp + ${CMAKE_CURRENT_LIST_DIR}/RiuWorkflowEditorWidget.cpp + ${CMAKE_CURRENT_LIST_DIR}/RiuWorkflowValidationRunner.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 2233caebe1..0550dda191 100644 --- a/ApplicationLibCode/UserInterface/RiuMainWindow.cpp +++ b/ApplicationLibCode/UserInterface/RiuMainWindow.cpp @@ -74,7 +74,7 @@ #include "RiuTools.h" #include "RiuTreeViewEventFilter.h" #include "RiuViewer.h" -#include "RiuWorkflowGraphView.h" +#include "RiuWorkflowEditorWidget.h" #include "cafAnimationToolBar.h" #include "cafCmdExecCommandManager.h" @@ -1284,8 +1284,8 @@ void RiuMainWindow::setPdmRoot( caf::PdmObject* pdmRoot ) m_pdmRoot = pdmRoot; if ( !pdmRoot ) { - m_displayedWorkflowJob = nullptr; - if ( m_workflowGraphView ) m_workflowGraphView->showGraph( {}, {} ); + if ( m_workflowEditor ) m_workflowEditor->clear(); + updateWorkflowDockTitle(); } for ( auto tv : projectTreeViews() ) @@ -1614,12 +1614,12 @@ void RiuMainWindow::selectedObjectsChanged() //-------------------------------------------------------------------------------------------------- void RiuMainWindow::workflowBindingChanged( const RimWorkflowFieldBinding* binding ) { - if ( !m_workflowGraphView || !m_displayedWorkflowJob || binding->firstAncestorOrThisOfType() != m_displayedWorkflowJob.p() ) + if ( !m_workflowEditor || !m_workflowEditor->job() || binding->firstAncestorOrThisOfType() != m_workflowEditor->job() ) return; if ( auto* task = binding->firstAncestorOrThisOfType() ) { - m_workflowGraphView->setTaskInputValue( task->taskName(), binding->fieldName(), binding->displayValue() ); + m_workflowEditor->graphView()->setTaskInputValue( task->taskName(), binding->fieldName(), binding->displayValue() ); } } @@ -1628,13 +1628,51 @@ void RiuMainWindow::workflowBindingChanged( const RimWorkflowFieldBinding* bindi //-------------------------------------------------------------------------------------------------- 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() ); + if ( !m_workflowEditor || m_workflowEditor->job() != job ) return; + m_workflowEditor->updateJobState(); +} + +//-------------------------------------------------------------------------------------------------- +/// Called by RimWorkflow after edits, undo, save and validation. A null workflow means the set of +/// workflows changed (rescan). +//-------------------------------------------------------------------------------------------------- +void RiuMainWindow::workflowDefinitionChanged( RimWorkflow* workflow ) +{ + if ( workflow ) workflow->updateConnectedEditors(); + if ( !m_workflowEditor ) return; + + RimWorkflow* displayed = m_workflowEditor->workflow(); + if ( !displayed ) + { + m_workflowEditor->clear(); + } + else if ( !workflow || workflow == displayed ) + { + RimWorkflowJob* job = m_workflowEditor->job(); + if ( !job ) + { + const auto jobs = displayed->jobs(); + if ( !jobs.empty() ) job = jobs.front(); + } + m_workflowEditor->setWorkflow( displayed, job ); + } + updateWorkflowDockTitle(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RimWorkflow* RiuMainWindow::displayedWorkflow() const +{ + return m_workflowEditor ? m_workflowEditor->workflow() : nullptr; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RiuWorkflowEditorWidget* RiuMainWindow::workflowEditor() const +{ + return m_workflowEditor; } //-------------------------------------------------------------------------------------------------- @@ -1645,31 +1683,57 @@ void RiuMainWindow::showWorkflowGraph( RimWorkflow* workflow, RimWorkflowJob* jo if ( !m_workflowGraphDock ) { m_workflowGraphDock = RiuDockWidgetTools::createDockWidget( "Workflow", "WorkflowGraph", dockManager() ); - m_workflowGraphView = new RiuWorkflowGraphView( m_workflowGraphDock ); - m_workflowGraphDock->setWidget( m_workflowGraphView ); + m_workflowEditor = new RiuWorkflowEditorWidget( m_workflowGraphDock ); + m_workflowGraphDock->setWidget( m_workflowEditor ); dockManager()->addDockWidget( ads::DockWidgetArea::CenterDockWidgetArea, m_workflowGraphDock, dockManager()->centralWidget()->dockAreaWidget() ); + connect( m_workflowEditor, &RiuWorkflowEditorWidget::taskSelected, this, &RiuMainWindow::showWorkflowTaskProperties ); } - 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_workflowEditor->setWorkflow( workflow, job ); + updateWorkflowDockTitle(); m_workflowGraphDock->toggleView( true ); m_workflowGraphDock->setAsCurrentTab(); } +//-------------------------------------------------------------------------------------------------- +/// Show the inputs of the selected graph node, or the job when the selection is cleared +//-------------------------------------------------------------------------------------------------- +void RiuMainWindow::showWorkflowTaskProperties( const QString& taskName ) +{ + if ( !m_workflowEditor ) return; + + RimWorkflowJob* job = m_workflowEditor->job(); + if ( !job ) return; + + if ( RimWorkflowTaskInput* task = taskName.isEmpty() ? nullptr : job->taskInput( taskName ) ) + { + m_pdmUiPropertyView->showProperties( task ); + } + else if ( dynamic_cast( m_pdmUiPropertyView->currentObject() ) ) + { + m_pdmUiPropertyView->showProperties( job ); + } +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuMainWindow::updateWorkflowDockTitle() +{ + if ( !m_workflowGraphDock ) return; + + RimWorkflow* workflow = displayedWorkflow(); + if ( !workflow ) + { + m_workflowGraphDock->setWindowTitle( "Workflow" ); + return; + } + const bool modified = workflow->isEditable() && workflow->isDirty(); + m_workflowGraphDock->setWindowTitle( QString( "Workflow: %1%2" ).arg( workflow->name(), modified ? "*" : "" ) ); +} + //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- diff --git a/ApplicationLibCode/UserInterface/RiuMainWindow.h b/ApplicationLibCode/UserInterface/RiuMainWindow.h index a0db85745e..6165236d81 100644 --- a/ApplicationLibCode/UserInterface/RiuMainWindow.h +++ b/ApplicationLibCode/UserInterface/RiuMainWindow.h @@ -57,7 +57,7 @@ class RiuPvtPlotPanel; class RiuMohrsCirclePlot; class RiuSeismicHistogramPanel; class RiuCellSelectionTool; -class RiuWorkflowGraphView; +class RiuWorkflowEditorWidget; class RicGridCalculatorDialog; @@ -131,8 +131,11 @@ public: RicGridCalculatorDialog* gridCalculatorDialog( bool createIfNotPresent ); - void workflowBindingChanged( const RimWorkflowFieldBinding* binding ); - void workflowJobStateChanged( const RimWorkflowJob* job ); + void workflowBindingChanged( const RimWorkflowFieldBinding* binding ); + void workflowJobStateChanged( const RimWorkflowJob* job ); + void workflowDefinitionChanged( RimWorkflow* workflow ); + RimWorkflow* displayedWorkflow() const; + RiuWorkflowEditorWidget* workflowEditor() const; protected: void closeEvent( QCloseEvent* event ) override; @@ -147,6 +150,8 @@ private: void createToolBars(); void createDockPanels(); void showWorkflowGraph( RimWorkflow* workflow, RimWorkflowJob* job ); + void showWorkflowTaskProperties( const QString& taskName ); + void updateWorkflowDockTitle(); void restoreTreeViewState(); @@ -287,6 +292,5 @@ private: std::vector> m_additionalProjectViews; QPointer m_workflowGraphDock; - QPointer m_workflowGraphView; - caf::PdmPointer m_displayedWorkflowJob; + QPointer m_workflowEditor; }; diff --git a/ApplicationLibCode/UserInterface/RiuWorkflowEditorWidget.cpp b/ApplicationLibCode/UserInterface/RiuWorkflowEditorWidget.cpp new file mode 100644 index 0000000000..a1dd2b943b --- /dev/null +++ b/ApplicationLibCode/UserInterface/RiuWorkflowEditorWidget.cpp @@ -0,0 +1,657 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RiuWorkflowEditorWidget.h" + +#include "RiuWorkflowTaskPalette.h" +#include "RiuWorkflowValidationRunner.h" + +#include "WorkflowCommands/RicWorkflowFeatureTools.h" + +#include "Workflow/RimWorkflow.h" +#include "Workflow/RimWorkflowDefinition.h" +#include "Workflow/RimWorkflowDefinitionTools.h" +#include "Workflow/RimWorkflowFieldBinding.h" +#include "Workflow/RimWorkflowJob.h" +#include "Workflow/RimWorkflowPortCompatibility.h" +#include "Workflow/RimWorkflowTaskCatalog.h" +#include "Workflow/RimWorkflowTaskInput.h" + +#include "cafCmdFeature.h" +#include "cafCmdFeatureManager.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace +{ +//-------------------------------------------------------------------------------------------------- +/// The action of a command feature carrying user data for the current menu +//-------------------------------------------------------------------------------------------------- +QAction* featureAction( const QString& commandId, const QString& text, const QVariantMap& userData ) +{ + auto* feature = caf::CmdFeatureManager::instance()->getCommandFeature( commandId.toStdString() ); + if ( !feature ) return nullptr; + + QAction* action = feature->actionWithUserData( text, userData ); + action->setCheckable( false ); + action->setEnabled( true ); + return action; +} +} // namespace + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RiuWorkflowEditorWidget::RiuWorkflowEditorWidget( QWidget* parent ) + : QWidget( parent ) +{ + auto* layout = new QVBoxLayout( this ); + layout->setContentsMargins( 0, 0, 0, 0 ); + layout->setSpacing( 2 ); + + m_toolBar = new QToolBar( this ); + m_toolBar->setIconSize( QSize( 16, 16 ) ); + m_toolBar->setToolButtonStyle( Qt::ToolButtonTextBesideIcon ); + + auto addAction = [this]( const QString& text, const QString& icon, const QKeySequence& shortcut, auto slot ) + { + auto* action = new QAction( icon.isEmpty() ? QIcon() : QIcon( icon ), text, this ); + if ( !shortcut.isEmpty() ) + { + action->setShortcut( shortcut ); + action->setShortcutContext( Qt::WidgetWithChildrenShortcut ); + action->setToolTip( QString( "%1 (%2)" ).arg( text, shortcut.toString( QKeySequence::NativeText ) ) ); + this->addAction( action ); + } + connect( action, &QAction::triggered, this, slot ); + m_toolBar->addAction( action ); + return action; + }; + + m_saveAction = addAction( "Save", + ":/Save.svg", + QKeySequence::Save, + [this]() + { + if ( m_workflow ) RicWorkflowFeatureTools::saveWorkflow( m_workflow ); + } ); + m_saveAsAction = addAction( "Save As...", + ":/SaveAs.svg", + QKeySequence(), + [this]() + { + if ( m_workflow ) RicWorkflowFeatureTools::saveWorkflowAs( m_workflow ); + } ); + m_toolBar->addSeparator(); + m_undoAction = addAction( "Undo", + ":/undo.png", + QKeySequence::Undo, + [this]() + { + if ( m_workflow && m_workflow->canUndo() && !m_workflow->isLocked() ) m_workflow->undo(); + } ); + m_redoAction = addAction( "Redo", + ":/redo.png", + QKeySequence( Qt::CTRL | Qt::Key_Y ), + [this]() + { + if ( m_workflow && m_workflow->canRedo() && !m_workflow->isLocked() ) m_workflow->redo(); + } ); + m_toolBar->addSeparator(); + m_duplicateAction = addAction( "Duplicate as Editable", + ":/Copy.svg", + QKeySequence(), + [this]() + { + if ( m_workflow ) RicWorkflowFeatureTools::duplicateAsEditable( m_workflow ); + } ); + m_fitAction = addAction( "Fit", "", QKeySequence(), [this]() { m_graphView->fitGraph(); } ); + layout->addWidget( m_toolBar ); + + m_splitter = new QSplitter( Qt::Horizontal, this ); + m_palette = new RiuWorkflowTaskPalette( m_splitter ); + m_graphView = new RiuWorkflowGraphView( m_splitter ); + m_splitter->addWidget( m_palette ); + m_splitter->addWidget( m_graphView ); + m_splitter->setStretchFactor( 0, 0 ); + m_splitter->setStretchFactor( 1, 1 ); + m_splitter->setSizes( { 220, 800 } ); + layout->addWidget( m_splitter, 1 ); + + m_statusLabel = new QLabel( this ); + m_statusLabel->setWordWrap( true ); + m_statusLabel->setTextInteractionFlags( Qt::TextSelectableByMouse ); + m_statusLabel->setContentsMargins( 4, 2, 4, 2 ); + layout->addWidget( m_statusLabel ); + + m_validationRunner = new RiuWorkflowValidationRunner( this ); + connect( m_validationRunner, &RiuWorkflowValidationRunner::validationStarted, this, &RiuWorkflowEditorWidget::updateStatus ); + connect( m_validationRunner, &RiuWorkflowValidationRunner::validationFinished, this, &RiuWorkflowEditorWidget::onValidationFinished ); + + m_graphView->setConnectionValidator( + [this]( const QString& from, const QString& output, const QString& to, const QString& input ) -> std::expected + { + if ( !m_workflow ) return std::unexpected( QString( "No workflow" ) ); + return RimWorkflowPortCompatibility::canConnect( m_workflow->definition(), from, output, to, input ); + } ); + + connect( m_graphView, &RiuWorkflowGraphView::nodeSelected, this, &RiuWorkflowEditorWidget::taskSelected ); + connect( m_graphView, &RiuWorkflowGraphView::connectRequested, this, &RiuWorkflowEditorWidget::connectPorts ); + connect( m_graphView, &RiuWorkflowGraphView::deleteRequested, this, &RiuWorkflowEditorWidget::deleteItem ); + connect( m_graphView, &RiuWorkflowGraphView::renameRequested, this, &RiuWorkflowEditorWidget::renameTask ); + connect( m_graphView, + &RiuWorkflowGraphView::addTaskRequested, + this, + [this]( const QString& taskId, const QPointF& scenePos ) { addTask( taskId, scenePos ); } ); + connect( m_graphView, &RiuWorkflowGraphView::contextMenuRequested, this, &RiuWorkflowEditorWidget::showContextMenu ); + connect( m_palette, &RiuWorkflowTaskPalette::taskActivated, this, [this]( const QString& taskId ) { addTask( taskId ); } ); + + updateActions(); + updateStatus(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RiuWorkflowEditorWidget::~RiuWorkflowEditorWidget() +{ + m_validationRunner->cancel(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::setWorkflow( RimWorkflow* workflow, RimWorkflowJob* job ) +{ + const bool workflowChanged = workflow != m_workflow.p(); + if ( workflowChanged ) + { + m_validationRunner->cancel(); + m_message.clear(); + } + + m_workflow = workflow; + m_job = job; + showWorkflow( workflowChanged ? RiuWorkflowGraphView::ViewportPolicy::Fit : RiuWorkflowGraphView::ViewportPolicy::Keep ); + scheduleValidation(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RimWorkflow* RiuWorkflowEditorWidget::workflow() const +{ + return m_workflow.p(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RimWorkflowJob* RiuWorkflowEditorWidget::job() const +{ + return m_job.p(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::clear() +{ + m_validationRunner->cancel(); + m_workflow = nullptr; + m_job = nullptr; + m_message.clear(); + showWorkflow( RiuWorkflowGraphView::ViewportPolicy::Fit ); +} + +//-------------------------------------------------------------------------------------------------- +/// Show the current revision of the workflow without moving the view +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::refresh() +{ + showWorkflow( RiuWorkflowGraphView::ViewportPolicy::Keep ); + scheduleValidation(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::updateJobState() +{ + m_graphView->resetTaskStates(); + if ( !m_job ) return; + + const auto states = m_job->taskStates(); + const auto errors = m_job->taskErrors(); + for ( auto it = states.cbegin(); it != states.cend(); ++it ) + m_graphView->setTaskState( it.key(), it.value(), errors.value( it.key() ) ); + m_graphView->setRunStatus( m_job->runStatus() ); + + // A running job locks the workflow + m_graphView->setEditable( m_workflow && m_workflow->isEditable() && !m_workflow->isLocked() ); + updateActions(); + updateStatus(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::updateTaskInputValues() +{ + if ( !m_job ) return; + for ( RimWorkflowTaskInput* task : m_job->taskInputs() ) + { + for ( RimWorkflowFieldBinding* binding : task->items() ) + { + if ( binding ) m_graphView->setTaskInputValue( task->taskName(), binding->fieldName(), binding->displayValue() ); + } + } +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RiuWorkflowGraphView* RiuWorkflowEditorWidget::graphView() const +{ + return m_graphView; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RiuWorkflowTaskPalette* RiuWorkflowEditorWidget::palette() const +{ + return m_palette; +} + +//-------------------------------------------------------------------------------------------------- +/// Apply an edit to the displayed workflow. The workflow notifies the main window, which refreshes +/// this widget. +//-------------------------------------------------------------------------------------------------- +bool RiuWorkflowEditorWidget::applyEdit( const std::expected& result, const QMap& renames ) +{ + if ( !m_workflow ) return false; + + auto applied = m_workflow->applyEdit( result, renames ); + if ( !applied ) + { + showMessage( applied.error() ); + return false; + } + + m_message.clear(); + updateActions(); + updateStatus(); + return true; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::addTask( const QString& taskId, const std::optional& scenePos ) +{ + if ( !m_workflow ) return; + + QString addedName; + const auto result = RimWorkflowDefinitionTools::addTask( m_workflow->definition(), RimWorkflowTaskCatalog::instance(), taskId, &addedName ); + if ( result && scenePos ) m_graphView->setPendingNodePosition( addedName, *scenePos ); + if ( applyEdit( result ) ) m_graphView->selectTask( addedName ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::deleteItem( const RiuWorkflowGraphView::ItemRef& item ) +{ + if ( !m_workflow ) return; + + if ( item.kind == RiuWorkflowGraphView::ItemRef::Kind::Task ) + { + applyEdit( RimWorkflowDefinitionTools::removeTask( m_workflow->definition(), item.task ) ); + } + else if ( item.kind == RiuWorkflowGraphView::ItemRef::Kind::Edge ) + { + const RimWorkflowDefinitionEdge edge{ .from = item.from, .output = item.output, .to = item.to, .input = item.input }; + applyEdit( RimWorkflowDefinitionTools::disconnect( m_workflow->definition(), edge ) ); + } +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::renameTask( const QString& taskName ) +{ + if ( !m_workflow || !m_workflow->definition().findNode( taskName ) ) return; + if ( !m_workflow->isEditable() || m_workflow->isLocked() ) + { + showMessage( m_workflow->isLocked() ? QString( "The workflow is locked while a job is running" ) : m_workflow->readOnlyReason() ); + return; + } + + bool ok = false; + const QString newName = + QInputDialog::getText( this, "Rename Task", "Task name (letters, digits and underscores):", QLineEdit::Normal, taskName, &ok ).trimmed(); + if ( !ok || newName == taskName ) return; + + const auto result = RimWorkflowDefinitionTools::renameTask( m_workflow->definition(), taskName, newName ); + if ( result ) m_graphView->renameNodePosition( taskName, newName ); + if ( applyEdit( result, { { taskName, newName } } ) ) + { + m_graphView->selectTask( newName ); + } + else if ( result ) + { + m_graphView->renameNodePosition( newName, taskName ); + } +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::setResultTask( const QString& taskName ) +{ + if ( !m_workflow ) return; + applyEdit( RimWorkflowDefinitionTools::setResultTask( m_workflow->definition(), taskName ) ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::setOptionalInputConfigured( const QString& taskName, const QString& field, bool configured ) +{ + if ( !m_workflow ) return; + applyEdit( RimWorkflowDefinitionTools::setOptionalInputConfigured( m_workflow->definition(), taskName, field, configured ) ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::connectPorts( const QString& from, const QString& output, const QString& to, const QString& input ) +{ + if ( !m_workflow ) return; + const RimWorkflowDefinitionEdge edge{ .from = from, .output = output, .to = to, .input = input }; + applyEdit( RimWorkflowDefinitionTools::connect( m_workflow->definition(), edge ) ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::showMessage( const QString& message ) +{ + m_message = message; + updateStatus(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::showWorkflow( RiuWorkflowGraphView::ViewportPolicy policy ) +{ + if ( !m_workflow ) + { + m_graphView->setEditable( false ); + m_graphView->showGraph( {}, {}, policy ); + } + else + { + m_graphView->setEditable( m_workflow->isEditable() && !m_workflow->isLocked() ); + m_graphView->showGraph( m_workflow->graph(), m_workflow->loadError(), policy ); + updateTaskInputValues(); + if ( m_job ) updateJobState(); + } + + m_palette->setEnabled( m_workflow && m_workflow->isEditable() && !m_workflow->isLocked() ); + updateActions(); + updateStatus(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::showContextMenu( const RiuWorkflowGraphView::ItemRef& item, const QPoint& globalPos, const QPointF& scenePos ) +{ + if ( !m_workflow ) return; + + const bool editable = m_workflow->isEditable() && !m_workflow->isLocked(); + + QMenu menu; + if ( item.kind == RiuWorkflowGraphView::ItemRef::Kind::Task && editable ) + { + const QVariantMap data{ { "task", item.task } }; + if ( auto* action = featureAction( "RicRenameWorkflowTaskFeature", "Rename...", data ) ) menu.addAction( action ); + if ( auto* action = featureAction( "RicSetWorkflowResultTaskFeature", "Set as Result Task", data ) ) + { + action->setEnabled( RimWorkflowDefinitionTools::effectiveResultTask( m_workflow->definition() ) != item.task || + m_workflow->definition().resultTask.isEmpty() ); + menu.addAction( action ); + } + appendOptionalInputsMenu( &menu, item.task ); + menu.addSeparator(); + if ( auto* action = featureAction( "RicDeleteWorkflowTaskFeature", "Delete Task", data ) ) menu.addAction( action ); + } + else if ( item.kind == RiuWorkflowGraphView::ItemRef::Kind::Edge && editable ) + { + const QVariantMap data{ { "from", item.from }, { "output", item.output }, { "to", item.to }, { "input", item.input } }; + if ( auto* action = featureAction( "RicDeleteWorkflowConnectionFeature", "Delete Connection", data ) ) menu.addAction( action ); + } + else + { + if ( editable ) + { + appendAddTaskMenu( &menu, scenePos ); + menu.addSeparator(); + } + menu.addAction( m_fitAction ); + if ( editable ) menu.addAction( m_saveAction ); + if ( !m_workflow->isEditable() ) menu.addAction( m_duplicateAction ); + } + + if ( !menu.isEmpty() ) menu.exec( globalPos ); +} + +//-------------------------------------------------------------------------------------------------- +/// The installed task types, grouped by package +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::appendAddTaskMenu( QMenu* menu, const QPointF& scenePos ) +{ + QMenu* addMenu = menu->addMenu( "Add Task" ); + + const RimWorkflowTaskCatalog& catalog = RimWorkflowTaskCatalog::instance(); + if ( !catalog.isValid() ) + { + addMenu->addAction( QString( "Task catalog unavailable: %1" ).arg( catalog.loadError() ) )->setEnabled( false ); + return; + } + + std::map> groups; + for ( const QJsonObject& task : catalog.tasks() ) + { + const QString id = task.value( "id" ).toString(); + groups[id.contains( '.' ) ? id.section( '.', 0, -2 ) : QString( "other" )].push_back( task ); + } + + QSet usedTexts; + for ( auto& [group, tasks] : groups ) + { + std::sort( tasks.begin(), + tasks.end(), + []( const QJsonObject& a, const QJsonObject& b ) { return a.value( "id" ).toString() < b.value( "id" ).toString(); } ); + + QMenu* groupMenu = groups.size() > 1 ? addMenu->addMenu( group ) : addMenu; + for ( const QJsonObject& task : tasks ) + { + const QString id = task.value( "id" ).toString(); + QString text = task.value( "name" ).toString( id ); + if ( text.isEmpty() || usedTexts.contains( text ) ) text = id; + usedTexts.insert( text ); + + const QVariantMap data{ { "taskId", id }, { "x", scenePos.x() }, { "y", scenePos.y() } }; + if ( auto* action = featureAction( "RicAddWorkflowTaskFeature", text, data ) ) + { + action->setToolTip( task.value( "description" ).toString() ); + groupMenu->addAction( action ); + } + } + } + addMenu->setToolTipsVisible( true ); +} + +//-------------------------------------------------------------------------------------------------- +/// Optional inputs are configured by the user only when checked; connected inputs are disabled +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::appendOptionalInputsMenu( QMenu* menu, const QString& taskName ) +{ + const RimWorkflowDefinition& definition = m_workflow->definition(); + const RimWorkflowDefinitionNode* node = definition.findNode( taskName ); + if ( !node ) return; + + std::vector optionalPorts; + for ( const auto& port : RimWorkflowPortCompatibility::inputPorts( definition.taskType( node->taskId ) ) ) + { + if ( !port.isWhole() && !port.required ) optionalPorts.push_back( port ); + } + if ( optionalPorts.empty() ) return; + + QStringList connected; + for ( const auto& edge : definition.incomingEdges( taskName ) ) + connected.append( edge.input ); + + QMenu* optionalMenu = menu->addMenu( "Optional Inputs" ); + for ( const auto& port : optionalPorts ) + { + const bool configured = node->explicitConfigFields.contains( port.name ); + const QVariantMap data{ { "task", taskName }, { "field", port.name }, { "configured", !configured } }; + if ( auto* action = featureAction( "RicToggleWorkflowOptionalInputFeature", port.name, data ) ) + { + action->setCheckable( true ); + action->setChecked( configured ); + action->setEnabled( !connected.contains( port.name ) ); + action->setToolTip( port.description ); + optionalMenu->addAction( action ); + } + } + optionalMenu->setToolTipsVisible( true ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::updateActions() +{ + RimWorkflow* workflow = m_workflow.p(); + const bool editable = workflow && workflow->isEditable() && !workflow->isLocked(); + + m_saveAction->setEnabled( editable && ( workflow->isDirty() || workflow->source() == RimWorkflow::Source::Unsaved ) ); + m_saveAsAction->setEnabled( editable ); + m_undoAction->setEnabled( editable && workflow->canUndo() ); + m_redoAction->setEnabled( editable && workflow->canRedo() ); + m_duplicateAction->setEnabled( workflow && workflow->loadError().isEmpty() && workflow->definition().editable ); + m_fitAction->setEnabled( workflow != nullptr ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::updateStatus() +{ + RimWorkflow* workflow = m_workflow.p(); + QStringList lines; + QString color; + + if ( !workflow ) + { + m_statusLabel->clear(); + return; + } + + if ( !workflow->loadError().isEmpty() ) + { + lines << workflow->loadError(); + color = "#c0392b"; + } + else if ( !workflow->isEditable() ) + { + lines << QString( "Read-only: %1. Use Duplicate as Editable to change it." ).arg( workflow->readOnlyReason() ); + color = "#7f8c8d"; + } + else + { + if ( !m_message.isEmpty() ) + { + lines << m_message; + color = "#c0392b"; + } + if ( workflow->isLocked() ) lines << "The workflow is locked while a job is running."; + + const QJsonArray issues = workflow->graph().value( "issues" ).toArray(); + for ( const QJsonValue& issue : issues ) + lines << issue.toObject().value( "message" ).toString(); + + if ( m_validationRunner->isRunning() || !workflow->isValidationCurrent() ) + { + lines << "Validating..."; + } + else if ( workflow->lastValidationFailed() ) + { + if ( issues.isEmpty() ) lines << "taskmaestro reports problems; see the marked tasks."; + if ( color.isEmpty() ) color = "#d35400"; + } + else if ( issues.isEmpty() && m_message.isEmpty() ) + { + lines << "Valid"; + if ( color.isEmpty() ) color = "#27ae60"; + } + if ( !issues.isEmpty() && color.isEmpty() ) color = "#d35400"; + + if ( workflow->source() == RimWorkflow::Source::Unsaved ) + lines << "Not saved yet"; + else if ( workflow->isDirty() ) + lines << "Unsaved changes"; + } + + m_statusLabel->setStyleSheet( color.isEmpty() ? QString() : QString( "color: %1;" ).arg( color ) ); + m_statusLabel->setText( lines.join( " | " ) ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::scheduleValidation() +{ + if ( m_workflow && m_workflow->isEditable() && !m_workflow->isValidationCurrent() ) m_validationRunner->schedule( m_workflow ); + updateStatus(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::onValidationFinished( RimWorkflow* workflow ) +{ + if ( workflow != m_workflow.p() ) return; + updateActions(); + updateStatus(); +} diff --git a/ApplicationLibCode/UserInterface/RiuWorkflowEditorWidget.h b/ApplicationLibCode/UserInterface/RiuWorkflowEditorWidget.h new file mode 100644 index 0000000000..bbd02bb22c --- /dev/null +++ b/ApplicationLibCode/UserInterface/RiuWorkflowEditorWidget.h @@ -0,0 +1,106 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RiuWorkflowGraphView.h" + +#include "cafPdmPointer.h" + +#include + +#include +#include + +class QAction; +class QLabel; +class QMenu; +class QSplitter; +class QToolBar; +class RimWorkflow; +class RimWorkflowJob; +struct RimWorkflowDefinition; +class RiuWorkflowTaskPalette; +class RiuWorkflowValidationRunner; + +//================================================================================================== +/// The workflow dock: a toolbar, the task palette, the graph and a status line. Edits go through +/// RimWorkflow::applyEdit(); the workflow then calls RiuMainWindow::workflowDefinitionChanged(), +/// which refreshes this widget. +//================================================================================================== +class RiuWorkflowEditorWidget : public QWidget +{ + Q_OBJECT + +public: + explicit RiuWorkflowEditorWidget( QWidget* parent = nullptr ); + ~RiuWorkflowEditorWidget() override; + + void setWorkflow( RimWorkflow* workflow, RimWorkflowJob* job ); + RimWorkflow* workflow() const; + RimWorkflowJob* job() const; + void clear(); + void refresh(); + void updateJobState(); + void updateTaskInputValues(); + + RiuWorkflowGraphView* graphView() const; + RiuWorkflowTaskPalette* palette() const; + + // Edit operations used by the graph commands. Errors are shown in the status line. + bool applyEdit( const std::expected& result, const QMap& renames = {} ); + void addTask( const QString& taskId, const std::optional& scenePos = std::nullopt ); + void deleteItem( const RiuWorkflowGraphView::ItemRef& item ); + void renameTask( const QString& taskName ); + void setResultTask( const QString& taskName ); + void setOptionalInputConfigured( const QString& taskName, const QString& field, bool configured ); + void connectPorts( const QString& from, const QString& output, const QString& to, const QString& input ); + + void showMessage( const QString& message ); + +signals: + void taskSelected( const QString& taskName ); + +private: + void showWorkflow( RiuWorkflowGraphView::ViewportPolicy policy ); + void showContextMenu( const RiuWorkflowGraphView::ItemRef& item, const QPoint& globalPos, const QPointF& scenePos ); + void appendAddTaskMenu( QMenu* menu, const QPointF& scenePos ); + void appendOptionalInputsMenu( QMenu* menu, const QString& taskName ); + void updateActions(); + void updateStatus(); + void scheduleValidation(); + void onValidationFinished( RimWorkflow* workflow ); + +private: + caf::PdmPointer m_workflow; + caf::PdmPointer m_job; + + QToolBar* m_toolBar; + QAction* m_saveAction; + QAction* m_saveAsAction; + QAction* m_undoAction; + QAction* m_redoAction; + QAction* m_duplicateAction; + QAction* m_fitAction; + QSplitter* m_splitter; + RiuWorkflowTaskPalette* m_palette; + RiuWorkflowGraphView* m_graphView; + QLabel* m_statusLabel; + RiuWorkflowValidationRunner* m_validationRunner; + QString m_message; +}; diff --git a/ApplicationLibCode/UserInterface/RiuWorkflowGraphLayout.cpp b/ApplicationLibCode/UserInterface/RiuWorkflowGraphLayout.cpp new file mode 100644 index 0000000000..9b48f838c0 --- /dev/null +++ b/ApplicationLibCode/UserInterface/RiuWorkflowGraphLayout.cpp @@ -0,0 +1,131 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RiuWorkflowGraphLayout.h" + +#include + +#include +#include + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RiuWorkflowGraphLayout::Layout RiuWorkflowGraphLayout::computeLayers( const QStringList& nodes, + const std::vector>& edges ) +{ + QStringList uniqueNodes = nodes; + uniqueNodes.removeDuplicates(); + uniqueNodes.removeAll( QString() ); + const QSet nodeSet( uniqueNodes.begin(), uniqueNodes.end() ); + + QMap predecessors; + QMap successors; + QSet> seen; + for ( const auto& [from, to] : edges ) + { + if ( from == to || !nodeSet.contains( from ) || !nodeSet.contains( to ) || seen.contains( { from, to } ) ) continue; + seen.insert( { from, to } ); + predecessors[to].append( from ); + successors[from].append( to ); + } + + Layout layout; + + QMap remainingIncoming; + for ( const QString& node : uniqueNodes ) + remainingIncoming[node] = predecessors.value( node ).size(); + + QSet placed; + QStringList ready; + for ( const QString& node : uniqueNodes ) + { + if ( remainingIncoming[node] == 0 ) ready.append( node ); + } + + while ( placed.size() < uniqueNodes.size() ) + { + if ( ready.isEmpty() ) + { + // A cycle: release the first unplaced node (in input order) to break it + for ( const QString& node : uniqueNodes ) + { + if ( !placed.contains( node ) ) + { + ready.append( node ); + break; + } + } + } + + const QString node = ready.takeFirst(); + if ( placed.contains( node ) ) continue; + placed.insert( node ); + + int rank = 0; + for ( const QString& predecessor : predecessors.value( node ) ) + { + if ( placed.contains( predecessor ) ) rank = std::max( rank, layout.ranks.value( predecessor ) + 1 ); + } + layout.ranks[node] = rank; + + for ( const QString& successor : successors.value( node ) ) + { + if ( placed.contains( successor ) ) continue; + if ( --remainingIncoming[successor] == 0 ) ready.append( successor ); + } + } + + int layerCount = 0; + for ( int rank : layout.ranks ) + layerCount = std::max( layerCount, rank + 1 ); + layout.layers.resize( layerCount ); + for ( auto it = layout.ranks.cbegin(); it != layout.ranks.cend(); ++it ) + layout.layers[it.value()].append( it.key() ); + + // One barycenter pass from left to right, ties broken by name + QMap positions; + for ( QStringList& layer : layout.layers ) + { + std::vector> keyed; + for ( const QString& node : layer ) + { + double sum = 0.0; + int count = 0; + for ( const QString& predecessor : predecessors.value( node ) ) + { + if ( positions.contains( predecessor ) && layout.ranks.value( predecessor ) < layout.ranks.value( node ) ) + { + sum += positions.value( predecessor ); + ++count; + } + } + keyed.emplace_back( count > 0 ? sum / count : std::numeric_limits::max(), node ); + } + std::sort( keyed.begin(), keyed.end() ); + + layer.clear(); + for ( size_t i = 0; i < keyed.size(); ++i ) + { + layer.append( keyed[i].second ); + positions[keyed[i].second] = static_cast( i ); + } + } + + return layout; +} diff --git a/ApplicationLibCode/UserInterface/RiuWorkflowGraphLayout.h b/ApplicationLibCode/UserInterface/RiuWorkflowGraphLayout.h new file mode 100644 index 0000000000..ced8cb7044 --- /dev/null +++ b/ApplicationLibCode/UserInterface/RiuWorkflowGraphLayout.h @@ -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. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include +#include + +#include +#include + +//================================================================================================== +/// Column assignment and ordering of workflow tasks for the graph view. Pure; positions in pixels +/// are left to the view, which knows the node sizes. +//================================================================================================== +namespace RiuWorkflowGraphLayout +{ +struct Layout +{ + QMap ranks; + std::vector layers; +}; + +// Longest-path ranks from a topological sort (Kahn), so the input does not need to be in +// topological order. Nodes on cycles are placed after their resolved predecessors. Within a +// column the nodes are ordered by the mean position of their predecessors, then by name. +Layout computeLayers( const QStringList& nodes, const std::vector>& edges ); +} // namespace RiuWorkflowGraphLayout diff --git a/ApplicationLibCode/UserInterface/RiuWorkflowGraphView.cpp b/ApplicationLibCode/UserInterface/RiuWorkflowGraphView.cpp index 995b3864a3..9fdfec39f0 100644 --- a/ApplicationLibCode/UserInterface/RiuWorkflowGraphView.cpp +++ b/ApplicationLibCode/UserInterface/RiuWorkflowGraphView.cpp @@ -16,6 +16,10 @@ #include "RiuWorkflowGraphView.h" +#include "RiuWorkflowGraphLayout.h" + +#include +#include #include #include #include @@ -23,15 +27,16 @@ #include #include #include -#include #include -#include +#include +#include #include #include #include +#include #include #include -#include +#include #include #include @@ -47,6 +52,34 @@ constexpr qreal portTop = 51.0; constexpr qreal columnStep = 650.0; constexpr qreal configGap = 110.0; constexpr qreal rowGap = 55.0; +constexpr qreal portRadius = 5.0; +constexpr qreal portHitDist = 14.0; + +const QColor taskBorder( 60, 85, 115 ); +const QColor taskBackground( 226, 238, 250 ); +const QColor unknownBackground( 232, 232, 232 ); +const QColor configBorder( 140, 105, 45 ); +const QColor configBackground( 253, 244, 218 ); +const QColor configPortColor( 170, 110, 35 ); +const QColor inputPortColor( 75, 145, 95 ); +const QColor outputPortColor( 50, 115, 165 ); +const QColor missingPortColor( 190, 60, 60 ); +const QColor validTargetColor( 40, 170, 70 ); +const QColor invalidTargetColor( 175, 175, 175 ); +const QColor errorColor( 190, 45, 45 ); +const QColor warningColor( 200, 130, 20 ); +const QColor edgeColor( 75, 95, 115 ); +const QColor selectedEdgeColor( 30, 120, 220 ); + +QString keyForField( const QString& field ) +{ + return field.isEmpty() ? "model" : "field:" + field; +} + +QString fieldForKey( const QString& key ) +{ + return key == "model" ? QString() : key.mid( 6 ); +} //-------------------------------------------------------------------------------------------------- /// @@ -62,95 +95,225 @@ QStringList fieldPorts( const QJsonArray& fields ) keys.sort(); return keys; } +} // namespace //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -QString inputPortKey( const QJsonObject& edge ) +namespace RiuWorkflowGraphViewItems { - 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; +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +//================================================================================================== +/// How a port is drawn. Hollow: optional and not used. Missing: required and not provided. +//================================================================================================== +struct PortInfo +{ + enum class Style + { + Normal, + Hollow, + Missing + }; + + QString key; + QString label; + QString toolTip; + Style style = Style::Normal; +}; + +//================================================================================================== +/// +//================================================================================================== +class PortItem : public QGraphicsEllipseItem +{ +public: + enum class Highlight + { + None, + Valid, + Invalid + }; + + PortItem( GraphNode* node, const PortInfo& info, bool output, bool config, const QPointF& center ); + + GraphNode* node() const { return m_node; } + QString key() const { return m_key; } + QString field() const { return fieldForKey( m_key ); } + bool isOutput() const { return m_output; } + bool isConfig() const { return m_config; } + void setHighlight( Highlight highlight, const QString& reason = {} ); + +private: + GraphNode* m_node; + QString m_key; + bool m_output; + bool m_config; + QPointF m_center; + QBrush m_brush; + QPen m_pen; + QString m_toolTip; +}; + +//================================================================================================== +/// +//================================================================================================== class GraphNode : public QGraphicsRectItem { public: - GraphNode( const QString& name, QStringList inputs, QStringList outputs, bool isConfig = false ); + GraphNode( const QString& name, const std::vector& inputs, const std::vector& 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 = {} ); + qreal height() const; + QPointF portPosition( const QString& key, bool output ) const; + void addEdge( GraphEdge* edge ); + QString name() const; + bool isConfig() const; + std::vector ports() const; + void setConfigValue( const QString& fieldName, const QString& value ); + void setTaskState( const QString& state, const QString& error = {} ); + void setTitle( const QString& title, const QString& toolTip ); + void setIssues( const QJsonArray& issues ); + void setUnknownType( bool unknown ); protected: QVariant itemChange( GraphicsItemChange change, const QVariant& value ) override; private: - void addPorts( const QStringList& keys, bool output ); + void addPorts( const std::vector& ports, bool output ); + void applyStyle(); - 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; + QString m_name; + bool m_isConfig; + qreal m_width; + QMap m_inputs; + QMap m_outputs; + QMap m_configValues; + std::vector m_edges; + QGraphicsTextItem* m_title = nullptr; + QGraphicsTextItem* m_stateLabel = nullptr; + QGraphicsTextItem* m_issueLabel = nullptr; + QString m_state; + QString m_stateError; + QString m_baseToolTip; + QString m_issueText; + int m_errorCount = 0; + int m_warningCount = 0; + bool m_unknownType = false; }; +//================================================================================================== +/// +//================================================================================================== class GraphEdge : public QGraphicsPathItem { public: - GraphEdge( GraphNode* from, QString outputKey, GraphNode* to, QString inputKey, bool isConfig = false ); + GraphEdge( GraphNode* from, QString outputKey, GraphNode* to, QString inputKey, bool isConfig, bool selectable ); - void updatePath(); + void updatePath(); + QPainterPath shape() const override; + bool isConfig() const { return m_isConfig; } + GraphNode* fromNode() const { return m_from; } + GraphNode* toNode() const { return m_to; } + QString outputField() const { return fieldForKey( m_outputKey ); } + QString inputField() const { return fieldForKey( m_inputKey ); } + +protected: + QVariant itemChange( GraphicsItemChange change, const QVariant& value ) override; private: + void applyPen(); + GraphNode* m_from; GraphNode* m_to; QString m_outputKey; QString m_inputKey; + bool m_isConfig; QGraphicsPolygonItem* m_arrow; }; //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -GraphNode::GraphNode( const QString& name, QStringList inputs, QStringList outputs, bool isConfig ) +PortItem::PortItem( GraphNode* node, const PortInfo& info, bool output, bool config, const QPointF& center ) + : QGraphicsEllipseItem( center.x() - portRadius, center.y() - portRadius, 2 * portRadius, 2 * portRadius, node ) + , m_node( node ) + , m_key( info.key ) + , m_output( output ) + , m_config( config ) + , m_center( center ) + , m_toolTip( info.toolTip.isEmpty() ? info.label : info.toolTip ) +{ + const QColor color = config ? configPortColor : ( output ? outputPortColor : inputPortColor ); + switch ( info.style ) + { + case PortInfo::Style::Normal: + m_brush = color; + m_pen = Qt::NoPen; + break; + case PortInfo::Style::Hollow: + m_brush = QColor( 255, 255, 255 ); + m_pen = QPen( color, 1.5 ); + break; + case PortInfo::Style::Missing: + m_brush = QColor( 255, 255, 255 ); + m_pen = QPen( missingPortColor, 2.0 ); + break; + } + setHighlight( Highlight::None ); + setAcceptedMouseButtons( Qt::NoButton ); + setZValue( 2 ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void PortItem::setHighlight( Highlight highlight, const QString& reason ) +{ + const qreal radius = highlight == Highlight::Valid ? portRadius * 1.8 : portRadius; + setRect( m_center.x() - radius, m_center.y() - radius, 2 * radius, 2 * radius ); + switch ( highlight ) + { + case Highlight::None: + setBrush( m_brush ); + setPen( m_pen ); + setToolTip( m_toolTip ); + break; + case Highlight::Valid: + setBrush( validTargetColor ); + setPen( QPen( validTargetColor.darker( 130 ), 1.5 ) ); + setToolTip( m_toolTip ); + break; + case Highlight::Invalid: + setBrush( invalidTargetColor ); + setPen( Qt::NoPen ); + setToolTip( reason.isEmpty() ? m_toolTip : m_toolTip + "\n\nCannot connect: " + reason ); + break; + } +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +GraphNode::GraphNode( const QString& name, const std::vector& inputs, const std::vector& outputs, bool isConfig ) : m_name( name ) , m_isConfig( isConfig ) , m_width( isConfig ? configWidth : nodeWidth ) + , m_baseToolTip( name ) { - 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 ) ); + const qsizetype rows = std::max( { qsizetype( 1 ), qsizetype( inputs.size() ), qsizetype( outputs.size() ) } ); + setRect( 0, 0, m_width, portTop + portStep * rows + 12 ); setFlags( ItemIsMovable | ItemIsSelectable | ItemSendsGeometryChanges ); setZValue( 1 ); - setToolTip( name ); + m_title = new QGraphicsTextItem( this ); 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 ); + m_title->setFont( titleFont ); + m_title->setPos( 10, 4 ); + m_title->setAcceptedMouseButtons( Qt::NoButton ); if ( !isConfig ) { @@ -161,10 +324,16 @@ GraphNode::GraphNode( const QString& name, QStringList inputs, QStringList outpu m_stateLabel->setFont( font ); m_stateLabel->setPos( m_width - 91, 5 ); m_stateLabel->setAcceptedMouseButtons( Qt::NoButton ); + + m_issueLabel = new QGraphicsTextItem( this ); + m_issueLabel->setFont( font ); + m_issueLabel->setPos( 8, rect().height() - 2 ); + m_issueLabel->setAcceptedMouseButtons( Qt::NoButton ); } addPorts( inputs, false ); addPorts( outputs, true ); + setTitle( isConfig ? "Config: " + name : name, {} ); } //-------------------------------------------------------------------------------------------------- @@ -178,29 +347,35 @@ qreal GraphNode::height() const //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -void GraphNode::addPorts( const QStringList& keys, bool output ) +void GraphNode::setTitle( const QString& title, const QString& toolTip ) { - auto& ports = output ? m_outputs : m_inputs; - for ( int row = 0; row < keys.size(); ++row ) + m_title->setPlainText( QFontMetrics( m_title->font() ).elidedText( title, Qt::ElideRight, m_width - ( m_isConfig ? 20 : 100 ) ) ); + m_title->setToolTip( title ); + if ( !toolTip.isEmpty() ) m_baseToolTip = toolTip; + applyStyle(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void GraphNode::addPorts( const std::vector& ports, bool output ) +{ + auto& portMap = output ? m_outputs : m_inputs; + for ( size_t row = 0; row < ports.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 ); + const PortInfo& info = ports[row]; + const qreal x = output ? m_width : 0.0; + const qreal y = portTop + row * portStep; + auto* port = new PortItem( this, info, output, m_isConfig, QPointF( x, y ) ); + portMap.insert( info.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 ); + const QString display = QFontMetrics( font ).elidedText( info.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->setToolTip( info.toolTip.isEmpty() ? info.label : info.toolTip ); + text->setDefaultTextColor( info.style == PortInfo::Style::Missing ? missingPortColor : QColor( 45, 65, 80 ) ); text->setPos( output ? m_width - text->boundingRect().width() - 12 : 10, y - ( m_isConfig || !output ? 20 : 13 ) ); text->setAcceptedMouseButtons( Qt::NoButton ); } @@ -240,40 +415,130 @@ bool GraphNode::isConfig() const return m_isConfig; } +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +std::vector GraphNode::ports() const +{ + std::vector result; + for ( PortItem* port : m_inputs ) + result.push_back( port ); + for ( PortItem* port : m_outputs ) + result.push_back( port ); + return result; +} + //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- void GraphNode::setTaskState( const QString& state, const QString& error ) { if ( m_isConfig ) return; + m_state = state; + m_stateError = error; + applyStyle(); +} - QColor border( 60, 85, 115 ); - QColor background( 226, 238, 250 ); - if ( state == "running" ) +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void GraphNode::setIssues( const QJsonArray& issues ) +{ + if ( m_isConfig ) return; + + m_errorCount = 0; + m_warningCount = 0; + QStringList lines; + for ( const QJsonValue& value : issues ) + { + const QJsonObject issue = value.toObject(); + const bool isError = issue.value( "severity" ).toString() != "warning"; + ( isError ? m_errorCount : m_warningCount )++; + lines.append( QString( "%1 %2" ).arg( isError ? "Error:" : "Warning:", issue.value( "message" ).toString() ) ); + } + m_issueText = lines.join( "\n" ); + applyStyle(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void GraphNode::setUnknownType( bool unknown ) +{ + m_unknownType = unknown; + applyStyle(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void GraphNode::applyStyle() +{ + if ( m_isConfig ) + { + setPen( QPen( configBorder ) ); + setBrush( configBackground ); + setToolTip( m_baseToolTip ); + return; + } + + QColor border = taskBorder; + QColor background = m_unknownType ? unknownBackground : taskBackground; + qreal width = 1.0; + auto style = Qt::SolidLine; + if ( m_state == "running" ) { border = QColor( 158, 112, 16 ); background = QColor( 255, 241, 189 ); } - else if ( state == "completed" ) + else if ( m_state == "completed" ) { border = QColor( 42, 120, 65 ); background = QColor( 215, 242, 222 ); } - else if ( state == "failed" ) + else if ( m_state == "failed" ) { border = QColor( 165, 50, 50 ); background = QColor( 253, 225, 225 ); } - else if ( state == "interrupted" ) + else if ( m_state == "interrupted" ) { border = QColor( 140, 85, 50 ); background = QColor( 245, 228, 210 ); } - setPen( QPen( border, state.isEmpty() ? 1 : 2 ) ); + else if ( m_errorCount > 0 || m_warningCount > 0 ) + { + border = m_errorCount > 0 ? errorColor : warningColor; + style = Qt::DashLine; + } + if ( !m_state.isEmpty() || m_errorCount > 0 || m_warningCount > 0 ) width = 2.0; + if ( isSelected() ) width += 1.0; + + QPen pen( border, width ); + pen.setStyle( style ); + setPen( pen ); 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 ); + + if ( m_stateLabel ) + { + m_stateLabel->setDefaultTextColor( border ); + m_stateLabel->setPlainText( m_state.isEmpty() ? "" : m_state.at( 0 ).toUpper() + m_state.mid( 1 ) ); + } + + if ( m_issueLabel ) + { + QStringList badge; + if ( m_errorCount > 0 ) badge << QString( "%1 error%2" ).arg( m_errorCount ).arg( m_errorCount > 1 ? "s" : "" ); + if ( m_warningCount > 0 ) badge << QString( "%1 warning%2" ).arg( m_warningCount ).arg( m_warningCount > 1 ? "s" : "" ); + m_issueLabel->setDefaultTextColor( m_errorCount > 0 ? errorColor : warningColor ); + m_issueLabel->setPlainText( badge.isEmpty() ? "" : QString::fromUtf8( "\u26a0 " ) + badge.join( ", " ) ); + m_issueLabel->setToolTip( m_issueText ); + } + + QStringList toolTip{ m_baseToolTip }; + if ( !m_stateError.isEmpty() ) toolTip << m_stateError; + if ( !m_issueText.isEmpty() ) toolTip << m_issueText; + setToolTip( toolTip.join( "\n\n" ) ); } //-------------------------------------------------------------------------------------------------- @@ -313,30 +578,53 @@ QVariant GraphNode::itemChange( GraphicsItemChange change, const QVariant& value edge->updatePath(); if ( scene() ) scene()->setSceneRect( scene()->sceneRect().united( scene()->itemsBoundingRect().adjusted( -50, -50, 50, 50 ) ) ); } + else if ( change == ItemSelectedHasChanged && m_title ) + { + applyStyle(); + } return QGraphicsRectItem::itemChange( change, value ); } //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -GraphEdge::GraphEdge( GraphNode* from, QString outputKey, GraphNode* to, QString inputKey, bool isConfig ) +GraphEdge::GraphEdge( GraphNode* from, QString outputKey, GraphNode* to, QString inputKey, bool isConfig, bool selectable ) : m_from( from ) , m_to( to ) , m_outputKey( std::move( outputKey ) ) , m_inputKey( std::move( inputKey ) ) + , m_isConfig( isConfig ) , 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 ); + if ( selectable ) + { + setFlag( ItemIsSelectable ); + setToolTip( QString( "%1.%2 \u2192 %3.%4" ) + .arg( from->name(), + outputField().isEmpty() ? "output" : outputField(), + to->name(), + inputField().isEmpty() ? "input" : inputField() ) ); + } m_arrow->setPen( Qt::NoPen ); - m_arrow->setBrush( pen.color() ); + applyPen(); m_from->addEdge( this ); m_to->addEdge( this ); updatePath(); } +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void GraphEdge::applyPen() +{ + const QColor color = isSelected() ? selectedEdgeColor : ( m_isConfig ? configPortColor : edgeColor ); + QPen pen( color, isSelected() ? 3.0 : 1.7 ); + if ( m_isConfig ) pen.setStyle( Qt::DashLine ); + setPen( pen ); + m_arrow->setBrush( color ); +} + //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- @@ -350,6 +638,97 @@ void GraphEdge::updatePath() setPath( path ); m_arrow->setPolygon( QPolygonF{ end, end + QPointF( -9, -5 ), end + QPointF( -9, 5 ) } ); } + +//-------------------------------------------------------------------------------------------------- +/// A wide stroke, so the thin curve is easy to click +//-------------------------------------------------------------------------------------------------- +QPainterPath GraphEdge::shape() const +{ + QPainterPathStroker stroker; + stroker.setWidth( 12 ); + return stroker.createStroke( path() ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QVariant GraphEdge::itemChange( GraphicsItemChange change, const QVariant& value ) +{ + if ( change == ItemSelectedHasChanged ) applyPen(); + return QGraphicsPathItem::itemChange( change, value ); +} +} // namespace RiuWorkflowGraphViewItems + +using namespace RiuWorkflowGraphViewItems; + +namespace +{ +//-------------------------------------------------------------------------------------------------- +/// Ports of a task in edit mode: every input and output the task type declares +//-------------------------------------------------------------------------------------------------- +std::pair, std::vector> editModePorts( const QJsonObject& task, const QJsonArray& edges ) +{ + const QString name = task.value( "name" ).toString(); + + std::vector inputs; + bool hasWholeInput = false; + for ( const QJsonValue& value : task.value( "input_ports" ).toArray() ) + { + const QJsonObject port = value.toObject(); + const QString field = port.value( "name" ).toString(); + const bool required = port.value( "required" ).toBool(); + const bool provided = port.value( "wired" ).toBool() || port.value( "configured" ).toBool() || port.value( "covered" ).toBool(); + if ( field.isEmpty() ) hasWholeInput = true; + + PortInfo info; + info.key = keyForField( field ); + info.label = field.isEmpty() ? "input" : field; + QStringList toolTip{ QString( "%1: %2" ).arg( info.label, port.value( "type" ).toString( "any" ) ) }; + if ( !port.value( "description" ).toString().isEmpty() ) toolTip << port.value( "description" ).toString(); + toolTip << ( required ? "Required" : "Optional" ); + if ( port.value( "configured" ).toBool() ) toolTip << "Set in the job configuration"; + if ( port.value( "covered" ).toBool() ) toolTip << "Provided by a whole-output connection"; + info.toolTip = toolTip.join( "\n" ); + if ( !provided && !field.isEmpty() ) info.style = required ? PortInfo::Style::Missing : PortInfo::Style::Hollow; + inputs.push_back( info ); + } + for ( const QJsonValue& value : edges ) + { + const QJsonObject edge = value.toObject(); + if ( edge.value( "to" ).toString() == name && edge.value( "input" ).toString().isEmpty() && !hasWholeInput ) + { + inputs.insert( inputs.begin(), PortInfo{ .key = "model", .label = "input", .toolTip = {} } ); + hasWholeInput = true; + } + } + + std::vector outputs; + for ( const QJsonValue& value : task.value( "output_ports" ).toArray() ) + { + const QJsonObject port = value.toObject(); + const QString field = port.value( "name" ).toString(); + PortInfo info; + info.key = keyForField( field ); + info.label = field.isEmpty() ? "output" : field; + QStringList toolTip{ QString( "%1: %2" ).arg( info.label, port.value( "type" ).toString( "any" ) ) }; + if ( !port.value( "description" ).toString().isEmpty() ) toolTip << port.value( "description" ).toString(); + info.toolTip = toolTip.join( "\n" ); + outputs.push_back( info ); + } + if ( outputs.empty() ) outputs.push_back( PortInfo{ .key = "model", .label = "output", .toolTip = {} } ); + return { inputs, outputs }; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +std::vector portInfos( const QStringList& keys, bool output ) +{ + std::vector result; + for ( const QString& key : keys ) + result.push_back( PortInfo{ .key = key, .label = key == "model" ? ( output ? "output" : "input" ) : key.mid( 6 ), .toolTip = {} } ); + return result; +} } // namespace //-------------------------------------------------------------------------------------------------- @@ -363,39 +742,136 @@ RiuWorkflowGraphView::RiuWorkflowGraphView( QWidget* parent ) setRenderHint( QPainter::Antialiasing ); setDragMode( QGraphicsView::ScrollHandDrag ); setTransformationAnchor( QGraphicsView::AnchorUnderMouse ); + setAcceptDrops( true ); + setFocusPolicy( Qt::StrongFocus ); + + connect( m_scene, &QGraphicsScene::selectionChanged, this, &RiuWorkflowGraphView::onSelectionChanged ); } //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -void RiuWorkflowGraphView::showGraph( const QJsonObject& graph, const QString& error ) +RiuWorkflowGraphView::~RiuWorkflowGraphView() = default; + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::setEditable( bool editable ) { + m_editable = editable; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RiuWorkflowGraphView::isEditable() const +{ + return m_editable; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::setConnectionValidator( const ConnectionValidator& validator ) +{ + m_validator = validator; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::setPendingNodePosition( const QString& taskName, const QPointF& scenePos ) +{ + m_pendingPositions[taskName] = scenePos; +} + +//-------------------------------------------------------------------------------------------------- +/// Keep the position of a task the user has placed when it is renamed +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::renameNodePosition( const QString& oldName, const QString& newName ) +{ + rememberPositions(); + for ( const QString& prefix : { QString( "task:" ), QString( "config:" ) } ) + { + if ( m_positions.contains( prefix + oldName ) ) m_positions[prefix + newName] = m_positions.take( prefix + oldName ); + } + if ( m_lastSelectedTask == oldName ) m_lastSelectedTask = newName; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::rememberPositions() +{ + for ( auto it = m_taskNodes.cbegin(); it != m_taskNodes.cend(); ++it ) + m_positions["task:" + it.key()] = it.value()->pos(); + for ( auto it = m_configNodes.cbegin(); it != m_configNodes.cend(); ++it ) + m_positions["config:" + it.key()] = it.value()->pos(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::fitGraph() +{ + m_fitOnResize = true; + if ( !m_scene->sceneRect().isEmpty() ) fitInView( m_scene->sceneRect(), Qt::KeepAspectRatio ); +} + +//-------------------------------------------------------------------------------------------------- +/// Rebuild the scene. With ViewportPolicy::Keep, tasks keep their positions and the view keeps +/// its zoom and scroll position, so edits do not make the graph jump. +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::showGraph( const QJsonObject& graph, const QString& error, ViewportPolicy policy ) +{ + finishConnectionDrag( false ); + + const QTransform oldTransform = transform(); + const QPointF oldCenter = mapToScene( viewport()->rect().center() ); + const bool keepViewport = policy == ViewportPolicy::Keep && !m_taskNodes.isEmpty() && !m_fitOnResize; + const QString selectedTask = m_lastSelectedTask; + if ( policy == ViewportPolicy::Keep ) + rememberPositions(); + else + m_positions.clear(); + + m_rebuilding = true; + m_taskNodes.clear(); + m_configNodes.clear(); m_runStatusLabel = nullptr; m_scene->clear(); - m_fitOnResize = true; + if ( policy == ViewportPolicy::Fit ) 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 ); + QString message = error; + if ( message.isEmpty() ) + message = m_editable ? "No tasks yet.\nDrag a task from the palette, or right-click to add one." : "No tasks in this workflow"; + m_scene->addText( message ); + m_scene->setSceneRect( m_scene->itemsBoundingRect().adjusted( -30, -30, 30, 30 ) ); + fitInView( m_scene->sceneRect(), Qt::KeepAspectRatio ); + m_rebuilding = false; + m_pendingPositions.clear(); 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; + const bool editMode = m_editable && graph.value( "editable" ).toBool(); + const QJsonArray tasks = graph.value( "tasks" ).toArray(); + const QJsonArray edges = graph.value( "edges" ).toArray(); + const QString result = graph.value( "result_task" ).toString(); + + QStringList taskNames; + std::vector> layoutEdges; + QMap taskData; + 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() ) ); + if ( name.isEmpty() || taskData.contains( name ) ) continue; + taskNames.append( name ); + taskData.insert( name, data ); + QStringList configured; for ( const QJsonValue& field : data.value( "config_fields" ).toArray() ) { @@ -405,88 +881,103 @@ void RiuWorkflowGraphView::showGraph( const QJsonObject& graph, const QString& e configured.removeDuplicates(); configured.sort(); configFields.insert( name, configured ); - inputs[name].append( configured ); } - - // taskmaestro describes tasks in topological order. - for ( const QJsonValue& task : tasks ) + for ( const QJsonValue& value : edges ) { - 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 ); - } - } + const QJsonObject edge = value.toObject(); + layoutEdges.emplace_back( edge.value( "from" ).toString(), edge.value( "to" ).toString() ); } + // Read-only graphs only show the ports in use, plus the declared outputs of end tasks + QMap usedInputs; + QMap usedOutputs; 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 ) ); + if ( !taskData.contains( from ) || !taskData.contains( to ) || from == to ) continue; + usedInputs[to].append( keyForField( edge.value( "input" ).toString() ) ); + usedOutputs[from].append( keyForField( edge.value( "output" ).toString() ) ); } - QMap layers; - QMap nodes; - QMap configNodes; - for ( auto it = ranks.cbegin(); it != ranks.cend(); ++it ) + for ( const QString& name : taskNames ) { - 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() ) + const QJsonObject data = taskData.value( name ); + + std::vector inputs; + std::vector outputs; + if ( editMode ) { - auto* configNode = new GraphNode( it.key(), {}, configFields.value( it.key() ), true ); + std::tie( inputs, outputs ) = editModePorts( data, edges ); + } + else + { + QStringList in = fieldPorts( data.value( "inputs" ).toArray() ) + usedInputs.value( name ); + in.append( configFields.value( name ) ); + QStringList out = usedOutputs.value( name ); + if ( out.isEmpty() ) out = fieldPorts( data.value( "outputs" ).toArray() ); + if ( out.isEmpty() ) out.append( "model" ); + in.removeDuplicates(); + out.removeDuplicates(); + in.sort(); + out.sort(); + inputs = portInfos( in, false ); + outputs = portInfos( out, true ); + } + + auto* node = new GraphNode( name, inputs, outputs ); + const QString taskId = data.value( "task_id" ).toString(); + const QString description = data.value( "description" ).toString(); + QStringList toolTip{ name }; + if ( !taskId.isEmpty() ) toolTip << taskId; + if ( !description.isEmpty() ) toolTip << description; + if ( name == result ) toolTip << "Result task: its output is the result of the workflow"; + if ( data.contains( "known_type" ) && !data.value( "known_type" ).toBool() ) + toolTip << "Unknown task type: the package providing it is not installed"; + node->setTitle( name == result ? QString::fromUtf8( "\u2605 " ) + name : name, toolTip.join( "\n" ) ); + node->setUnknownType( data.contains( "known_type" ) && !data.value( "known_type" ).toBool() ); + node->setIssues( data.value( "issues" ).toArray() ); + m_scene->addItem( node ); + m_taskNodes.insert( name, node ); + + if ( !configFields.value( name ).isEmpty() ) + { + auto* configNode = new GraphNode( name, {}, portInfos( configFields.value( name ), true ), true ); m_scene->addItem( configNode ); - configNodes.insert( it.key(), configNode ); + m_configNodes.insert( name, configNode ); } } - auto slotHeight = [&nodes, &configNodes]( const QString& name ) + auto slotHeight = [this]( const QString& name ) { - qreal height = nodes.value( name )->height(); - if ( auto* config = configNodes.value( name, nullptr ) ) height = std::max( height, config->height() ); + qreal height = m_taskNodes.value( name )->height(); + if ( auto* config = m_configNodes.value( name, nullptr ) ) height = std::max( height, config->height() ); return height; }; - qreal totalHeight = 0; - for ( const QStringList& layer : layers ) + const auto layout = RiuWorkflowGraphLayout::computeLayers( taskNames, layoutEdges ); + qreal totalHeight = 0; + for ( const QStringList& layer : layout.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 ) + for ( size_t column = 0; column < layout.layers.size(); ++column ) { - qreal layerHeight = 0; - for ( const QString& name : it.value() ) + const QStringList& layer = layout.layers[column]; + qreal layerHeight = 0; + for ( const QString& name : layer ) layerHeight += slotHeight( name ) + rowGap; qreal y = ( totalHeight - layerHeight + rowGap ) / 2.0; - for ( const QString& name : it.value() ) + for ( const QString& name : layer ) { - auto* node = nodes.value( name ); - const qreal x = it.key() * columnStep + configWidth + configGap; + auto* node = m_taskNodes.value( name ); + const qreal x = column * columnStep + configWidth + configGap; node->setPos( x, y + ( slotHeight( name ) - node->height() ) / 2.0 ); - if ( auto* config = configNodes.value( name, nullptr ) ) + if ( auto* config = m_configNodes.value( name, nullptr ) ) { config->setPos( x - configWidth - configGap, y + ( slotHeight( name ) - config->height() ) / 2.0 ); } @@ -494,20 +985,55 @@ void RiuWorkflowGraphView::showGraph( const QJsonObject& graph, const QString& e } } + // Positions the user (or a drop) has chosen override the layout + for ( auto it = m_taskNodes.cbegin(); it != m_taskNodes.cend(); ++it ) + { + GraphNode* config = m_configNodes.value( it.key(), nullptr ); + const QPointF layoutPos = it.value()->pos(); + QPointF position = layoutPos; + bool positioned = false; + if ( m_pendingPositions.contains( it.key() ) ) + { + position = m_pendingPositions.value( it.key() ); + positioned = true; + } + else if ( m_positions.contains( "task:" + it.key() ) ) + { + position = m_positions.value( "task:" + it.key() ); + positioned = true; + } + it.value()->setPos( position ); + + if ( config ) + { + if ( m_positions.contains( "config:" + it.key() ) && !m_pendingPositions.contains( it.key() ) ) + config->setPos( m_positions.value( "config:" + it.key() ) ); + else if ( positioned ) + config->setPos( position + QPointF( -configWidth - configGap, 0 ) ); + } + } + m_pendingPositions.clear(); + + const bool selectableEdges = editMode; 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 ) ) ); + if ( !m_taskNodes.contains( from ) || !m_taskNodes.contains( to ) || from == to ) continue; + m_scene->addItem( new GraphEdge( m_taskNodes.value( from ), + keyForField( edge.value( "output" ).toString() ), + m_taskNodes.value( to ), + keyForField( edge.value( "input" ).toString() ), + false, + selectableEdges ) ); } - for ( auto it = configNodes.cbegin(); it != configNodes.cend(); ++it ) + for ( auto it = m_configNodes.cbegin(); it != m_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_scene->addItem( new GraphEdge( it.value(), key, m_taskNodes.value( it.key() ), key, true, false ) ); } } @@ -515,7 +1041,104 @@ void RiuWorkflowGraphView::showGraph( const QJsonObject& graph, const QString& e 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 ); + + if ( keepViewport ) + { + setTransform( oldTransform ); + centerOn( oldCenter ); + } + else + { + fitInView( m_scene->sceneRect(), Qt::KeepAspectRatio ); + } + + m_lastSelectedTask.clear(); + if ( auto* node = m_taskNodes.value( selectedTask, nullptr ) ) + { + node->setSelected( true ); + m_lastSelectedTask = selectedTask; + } + m_rebuilding = false; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::setTaskIssues( const QString& taskName, const QJsonArray& issues ) +{ + if ( auto* node = m_taskNodes.value( taskName, nullptr ) ) node->setIssues( issues ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::selectTask( const QString& taskName ) +{ + auto* node = m_taskNodes.value( taskName, nullptr ); + if ( !node ) return; + + m_rebuilding = true; + m_scene->clearSelection(); + node->setSelected( true ); + m_lastSelectedTask = taskName; + m_rebuilding = false; + ensureVisible( node ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RiuWorkflowGraphView::ItemRef RiuWorkflowGraphView::ItemRef::forTask( const QString& taskName ) +{ + ItemRef item; + item.kind = Kind::Task; + item.task = taskName; + return item; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RiuWorkflowGraphView::ItemRef + RiuWorkflowGraphView::ItemRef::forEdge( const QString& from, const QString& output, const QString& to, const QString& input ) +{ + ItemRef item; + item.kind = Kind::Edge; + item.from = from; + item.output = output; + item.to = to; + item.input = input; + return item; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RiuWorkflowGraphView::ItemRef RiuWorkflowGraphView::selectedItem() const +{ + for ( QGraphicsItem* item : m_scene->selectedItems() ) + { + if ( auto* node = dynamic_cast( item ) ) return ItemRef::forTask( node->name() ); + if ( auto* edge = dynamic_cast( item ); edge && !edge->isConfig() ) + { + return ItemRef::forEdge( edge->fromNode()->name(), edge->outputField(), edge->toNode()->name(), edge->inputField() ); + } + } + return {}; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::onSelectionChanged() +{ + if ( m_rebuilding ) return; + + const ItemRef selected = selectedItem(); + if ( selected.kind != ItemRef::Kind::Task || selected.task == m_lastSelectedTask ) return; + + m_lastSelectedTask = selected.task; + emit nodeSelected( selected.task ); } //-------------------------------------------------------------------------------------------------- @@ -523,15 +1146,7 @@ void RiuWorkflowGraphView::showGraph( const QJsonObject& graph, const QString& e //-------------------------------------------------------------------------------------------------- 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; - } - } + if ( auto* node = m_configNodes.value( taskName, nullptr ) ) node->setConfigValue( fieldName, value ); } //-------------------------------------------------------------------------------------------------- @@ -539,10 +1154,8 @@ void RiuWorkflowGraphView::setTaskInputValue( const QString& taskName, const QSt //-------------------------------------------------------------------------------------------------- void RiuWorkflowGraphView::resetTaskStates() { - for ( QGraphicsItem* item : m_scene->items() ) - { - if ( auto* node = dynamic_cast( item ) ) node->setTaskState( {} ); - } + for ( GraphNode* node : m_taskNodes ) + node->setTaskState( {} ); } //-------------------------------------------------------------------------------------------------- @@ -550,15 +1163,7 @@ void RiuWorkflowGraphView::resetTaskStates() //-------------------------------------------------------------------------------------------------- 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; - } - } + if ( auto* node = m_taskNodes.value( taskName, nullptr ) ) node->setTaskState( state, error ); } //-------------------------------------------------------------------------------------------------- @@ -571,6 +1176,156 @@ void RiuWorkflowGraphView::setRunStatus( const QString& status ) m_runStatusLabel->setToolTip( status ); } +//-------------------------------------------------------------------------------------------------- +/// The task port nearest to the cursor, within a few pixels +//-------------------------------------------------------------------------------------------------- +PortItem* RiuWorkflowGraphView::portAt( const QPoint& viewPos ) const +{ + const QPointF scenePos = mapToScene( viewPos ); + const qreal distance = portHitDist / std::max( 0.05, transform().m11() ); + PortItem* nearest = nullptr; + qreal best = distance; + for ( QGraphicsItem* item : m_scene->items( QRectF( scenePos - QPointF( distance, distance ), QSizeF( 2 * distance, 2 * distance ) ) ) ) + { + auto* port = dynamic_cast( item ); + if ( !port || port->isConfig() ) continue; + const QPointF delta = port->mapToScene( port->rect().center() ) - scenePos; + const qreal d = std::hypot( delta.x(), delta.y() ); + if ( d <= best ) + { + best = d; + nearest = port; + } + } + return nearest; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RiuWorkflowGraphView::ItemRef RiuWorkflowGraphView::itemRefAt( const QPoint& viewPos ) const +{ + for ( QGraphicsItem* item : items( viewPos ) ) + { + QGraphicsItem* current = item; + while ( current ) + { + if ( auto* node = dynamic_cast( current ) ) return ItemRef::forTask( node->name() ); + if ( auto* edge = dynamic_cast( current ) ) + { + if ( edge->isConfig() ) break; + return ItemRef::forEdge( edge->fromNode()->name(), edge->outputField(), edge->toNode()->name(), edge->inputField() ); + } + current = current->parentItem(); + } + } + return {}; +} + +//-------------------------------------------------------------------------------------------------- +/// Highlight the ports the dragged port can connect to +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::startConnectionDrag( PortItem* port ) +{ + m_dragPort = port; + m_dragInvalidReasons.clear(); + + for ( GraphNode* node : m_taskNodes ) + { + for ( PortItem* candidate : node->ports() ) + { + if ( candidate->isOutput() == port->isOutput() ) continue; + + PortItem* outputPort = port->isOutput() ? port : candidate; + PortItem* inputPort = port->isOutput() ? candidate : port; + + std::expected valid; + if ( outputPort->node() == inputPort->node() ) + valid = std::unexpected( QString( "A task cannot be connected to itself" ) ); + else if ( m_validator ) + valid = m_validator( outputPort->node()->name(), outputPort->field(), inputPort->node()->name(), inputPort->field() ); + + if ( valid ) + candidate->setHighlight( PortItem::Highlight::Valid ); + else + { + candidate->setHighlight( PortItem::Highlight::Invalid, valid.error() ); + m_dragInvalidReasons.insert( candidate, valid.error() ); + } + } + } + + m_dragPath = m_scene->addPath( QPainterPath(), QPen( validTargetColor.darker( 120 ), 2.0, Qt::DashLine ) ); + m_dragPath->setZValue( 3 ); + updateConnectionDrag( port->mapToScene( port->rect().center() ) ); + setCursor( Qt::CrossCursor ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::updateConnectionDrag( const QPointF& scenePos ) +{ + if ( !m_dragPort || !m_dragPath ) return; + + m_dragTarget = nullptr; + QPointF end = scenePos; + if ( PortItem* target = portAt( mapFromScene( scenePos ) ); + target && target->isOutput() != m_dragPort->isOutput() && target->node() != m_dragPort->node() ) + { + if ( !m_dragInvalidReasons.contains( target ) ) + { + m_dragTarget = target; + end = target->mapToScene( target->rect().center() ); + } + else + { + QToolTip::showText( mapToGlobal( mapFromScene( scenePos ) ), m_dragInvalidReasons.value( target ), this ); + } + } + + const QPointF start = m_dragPort->mapToScene( m_dragPort->rect().center() ); + QPointF from = m_dragPort->isOutput() ? start : end; + QPointF to = m_dragPort->isOutput() ? end : start; + const qreal bend = std::max( 35.0, std::abs( to.x() - from.x() ) / 2.0 ); + QPainterPath path( from ); + path.cubicTo( from + QPointF( bend, 0 ), to - QPointF( bend, 0 ), to ); + m_dragPath->setPath( path ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::finishConnectionDrag( bool connect ) +{ + if ( !m_dragPort ) return; + + PortItem* source = m_dragPort; + PortItem* target = m_dragTarget; + m_dragPort = nullptr; + m_dragTarget = nullptr; + m_dragInvalidReasons.clear(); + if ( m_dragPath ) + { + m_scene->removeItem( m_dragPath ); + delete m_dragPath; + m_dragPath = nullptr; + } + for ( GraphNode* node : m_taskNodes ) + { + for ( PortItem* port : node->ports() ) + port->setHighlight( PortItem::Highlight::None ); + } + unsetCursor(); + QToolTip::hideText(); + + if ( !connect || !target ) return; + + PortItem* outputPort = source->isOutput() ? source : target; + PortItem* inputPort = source->isOutput() ? target : source; + emit connectRequested( outputPort->node()->name(), outputPort->field(), inputPort->node()->name(), inputPort->field() ); +} + //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- @@ -586,9 +1341,48 @@ void RiuWorkflowGraphView::resizeEvent( QResizeEvent* event ) void RiuWorkflowGraphView::mousePressEvent( QMouseEvent* event ) { if ( event->button() == Qt::LeftButton ) m_fitOnResize = false; + + if ( m_editable && event->button() == Qt::LeftButton ) + { + if ( PortItem* port = portAt( event->pos() ) ) + { + startConnectionDrag( port ); + event->accept(); + return; + } + } QGraphicsView::mousePressEvent( event ); } +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::mouseMoveEvent( QMouseEvent* event ) +{ + if ( m_dragPort ) + { + updateConnectionDrag( mapToScene( event->pos() ) ); + event->accept(); + return; + } + QGraphicsView::mouseMoveEvent( event ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::mouseReleaseEvent( QMouseEvent* event ) +{ + if ( m_dragPort ) + { + updateConnectionDrag( mapToScene( event->pos() ) ); + finishConnectionDrag( true ); + event->accept(); + return; + } + QGraphicsView::mouseReleaseEvent( event ); +} + //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- @@ -604,3 +1398,100 @@ void RiuWorkflowGraphView::wheelEvent( QWheelEvent* event ) } QGraphicsView::wheelEvent( event ); } + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::keyPressEvent( QKeyEvent* event ) +{ + if ( event->key() == Qt::Key_Escape && m_dragPort ) + { + finishConnectionDrag( false ); + event->accept(); + return; + } + + if ( m_editable ) + { + const ItemRef selected = selectedItem(); + if ( ( event->key() == Qt::Key_Delete || event->key() == Qt::Key_Backspace ) && selected.kind != ItemRef::Kind::None ) + { + emit deleteRequested( selected ); + event->accept(); + return; + } + if ( event->key() == Qt::Key_F2 && selected.kind == ItemRef::Kind::Task ) + { + emit renameRequested( selected.task ); + event->accept(); + return; + } + } + QGraphicsView::keyPressEvent( event ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::contextMenuEvent( QContextMenuEvent* event ) +{ + const ItemRef item = itemRefAt( event->pos() ); + + m_rebuilding = true; + m_scene->clearSelection(); + for ( QGraphicsItem* graphicsItem : items( event->pos() ) ) + { + QGraphicsItem* current = graphicsItem; + while ( current && !( current->flags() & QGraphicsItem::ItemIsSelectable ) ) + current = current->parentItem(); + if ( current ) + { + current->setSelected( true ); + break; + } + } + m_rebuilding = false; + onSelectionChanged(); + + emit contextMenuRequested( item, event->globalPos(), mapToScene( event->pos() ) ); + event->accept(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::dragEnterEvent( QDragEnterEvent* event ) +{ + if ( m_editable && event->mimeData()->hasFormat( taskMimeType ) ) + event->acceptProposedAction(); + else + event->ignore(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::dragMoveEvent( QDragMoveEvent* event ) +{ + if ( m_editable && event->mimeData()->hasFormat( taskMimeType ) ) + event->acceptProposedAction(); + else + event->ignore(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::dropEvent( QDropEvent* event ) +{ + if ( !m_editable || !event->mimeData()->hasFormat( taskMimeType ) ) + { + event->ignore(); + return; + } + + const QString taskId = QString::fromUtf8( event->mimeData()->data( taskMimeType ) ); + event->acceptProposedAction(); + m_fitOnResize = false; + emit addTaskRequested( taskId, mapToScene( event->position().toPoint() ) ); +} diff --git a/ApplicationLibCode/UserInterface/RiuWorkflowGraphView.h b/ApplicationLibCode/UserInterface/RiuWorkflowGraphView.h index 99236e10bc..3722f7c78b 100644 --- a/ApplicationLibCode/UserInterface/RiuWorkflowGraphView.h +++ b/ApplicationLibCode/UserInterface/RiuWorkflowGraphView.h @@ -14,35 +14,137 @@ // ///////////////////////////////////////////////////////////////////////////////// +#pragma once #pragma once #include +#include #include +#include +#include + +#include +#include class QGraphicsScene; +class QGraphicsPathItem; class QGraphicsTextItem; -class QMouseEvent; -class QResizeEvent; -class QWheelEvent; +namespace RiuWorkflowGraphViewItems +{ +class GraphNode; +class PortItem; +} // namespace RiuWorkflowGraphViewItems + +//================================================================================================== +/// Node graph of a workflow. Shows run states of a job and, in edit mode, lets the user connect +/// ports by dragging, select and delete tasks and connections, and drop tasks from the palette. +/// The view never changes the workflow itself; it emits requests handled by the editor widget. +//================================================================================================== class RiuWorkflowGraphView : public QGraphicsView { -public: - explicit RiuWorkflowGraphView( QWidget* parent = nullptr ); + Q_OBJECT + +public: + enum class ViewportPolicy + { + Fit, + Keep + }; + + struct ItemRef + { + enum class Kind + { + None, + Task, + Edge + }; + + Kind kind = Kind::None; + QString task; + QString from; + QString output; + QString to; + QString input; + + static ItemRef forTask( const QString& taskName ); + static ItemRef forEdge( const QString& from, const QString& output, const QString& to, const QString& input ); + }; + + // Returns why a connection is not possible. Fields are empty for the whole input or output. + using ConnectionValidator = + std::function( const QString& from, const QString& output, const QString& to, const QString& input )>; + + static constexpr const char* taskMimeType = "application/x-resinsight-workflow-task"; + + explicit RiuWorkflowGraphView( QWidget* parent = nullptr ); + ~RiuWorkflowGraphView() override; + + void showGraph( const QJsonObject& graph, const QString& error, ViewportPolicy policy = ViewportPolicy::Fit ); + void fitGraph(); + + void setEditable( bool editable ); + bool isEditable() const; + void setConnectionValidator( const ConnectionValidator& validator ); + void setTaskIssues( const QString& taskName, const QJsonArray& issues ); + + void setPendingNodePosition( const QString& taskName, const QPointF& scenePos ); + void renameNodePosition( const QString& oldName, const QString& newName ); + void selectTask( const QString& taskName ); + ItemRef selectedItem() const; - 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 ); +signals: + void nodeSelected( const QString& taskName ); + void connectRequested( const QString& from, const QString& outputField, const QString& to, const QString& inputField ); + void deleteRequested( const RiuWorkflowGraphView::ItemRef& item ); + void renameRequested( const QString& taskName ); + void addTaskRequested( const QString& taskId, const QPointF& scenePos ); + void contextMenuRequested( const RiuWorkflowGraphView::ItemRef& item, const QPoint& globalPos, const QPointF& scenePos ); + protected: void resizeEvent( QResizeEvent* event ) override; void mousePressEvent( QMouseEvent* event ) override; + void mouseMoveEvent( QMouseEvent* event ) override; + void mouseReleaseEvent( QMouseEvent* event ) override; void wheelEvent( QWheelEvent* event ) override; + void keyPressEvent( QKeyEvent* event ) override; + void contextMenuEvent( QContextMenuEvent* event ) override; + void dragEnterEvent( QDragEnterEvent* event ) override; + void dragMoveEvent( QDragMoveEvent* event ) override; + void dropEvent( QDropEvent* event ) override; private: - QGraphicsScene* m_scene; - QGraphicsTextItem* m_runStatusLabel = nullptr; - bool m_fitOnResize = true; + void onSelectionChanged(); + void rememberPositions(); + + RiuWorkflowGraphViewItems::PortItem* portAt( const QPoint& viewPos ) const; + ItemRef itemRefAt( const QPoint& viewPos ) const; + void startConnectionDrag( RiuWorkflowGraphViewItems::PortItem* port ); + void updateConnectionDrag( const QPointF& scenePos ); + void finishConnectionDrag( bool connect ); + +private: + QGraphicsScene* m_scene; + QGraphicsTextItem* m_runStatusLabel = nullptr; + bool m_fitOnResize = true; + bool m_editable = false; + bool m_rebuilding = false; + ConnectionValidator m_validator; + QString m_lastSelectedTask; + + QMap m_taskNodes; + QMap m_configNodes; + QMap m_positions; + QMap m_pendingPositions; + + RiuWorkflowGraphViewItems::PortItem* m_dragPort = nullptr; + RiuWorkflowGraphViewItems::PortItem* m_dragTarget = nullptr; + QGraphicsPathItem* m_dragPath = nullptr; + QMap m_dragInvalidReasons; }; diff --git a/ApplicationLibCode/UserInterface/RiuWorkflowTaskPalette.cpp b/ApplicationLibCode/UserInterface/RiuWorkflowTaskPalette.cpp new file mode 100644 index 0000000000..16b79e2b4e --- /dev/null +++ b/ApplicationLibCode/UserInterface/RiuWorkflowTaskPalette.cpp @@ -0,0 +1,194 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RiuWorkflowTaskPalette.h" + +#include "RiuWorkflowGraphView.h" + +#include "Workflow/RimWorkflowPortCompatibility.h" +#include "Workflow/RimWorkflowTaskCatalog.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr int taskIdRole = Qt::UserRole + 1; +constexpr int searchTextRole = Qt::UserRole + 2; + +//================================================================================================== +/// Puts the task id under the workflow task mime type when a task is dragged +//================================================================================================== +class TaskModel : public QStandardItemModel +{ +public: + using QStandardItemModel::QStandardItemModel; + + QStringList mimeTypes() const override { return { RiuWorkflowGraphView::taskMimeType }; } + + QMimeData* mimeData( const QModelIndexList& indexes ) const override + { + for ( const QModelIndex& index : indexes ) + { + const QString taskId = index.data( taskIdRole ).toString(); + if ( taskId.isEmpty() ) continue; + + auto* data = new QMimeData; + data->setData( RiuWorkflowGraphView::taskMimeType, taskId.toUtf8() ); + data->setText( taskId ); + return data; + } + return nullptr; + } +}; + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QString portSummary( const std::vector& ports ) +{ + QStringList lines; + for ( const auto& port : ports ) + { + QString line = QString( " %1: %2" ).arg( port.isWhole() ? "(whole)" : port.name, port.typeName ); + if ( !port.required ) line += " (optional)"; + lines << line; + } + return lines.isEmpty() ? " (none)" : lines.join( "\n" ); +} + +//-------------------------------------------------------------------------------------------------- +/// "resinsight.load_model" is in the "resinsight" group +//-------------------------------------------------------------------------------------------------- +QString groupName( const QString& taskId ) +{ + const int dot = taskId.lastIndexOf( '.' ); + return dot > 0 ? taskId.left( dot ) : QString( "Other" ); +} +} // namespace + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RiuWorkflowTaskPalette::RiuWorkflowTaskPalette( QWidget* parent ) + : QWidget( parent ) + , m_filter( new QLineEdit( this ) ) + , m_treeView( new QTreeView( this ) ) + , m_model( new TaskModel( this ) ) + , m_proxyModel( new QSortFilterProxyModel( this ) ) +{ + m_filter->setPlaceholderText( "Filter tasks" ); + m_filter->setClearButtonEnabled( true ); + + m_proxyModel->setSourceModel( m_model ); + m_proxyModel->setFilterRole( searchTextRole ); + m_proxyModel->setFilterCaseSensitivity( Qt::CaseInsensitive ); + m_proxyModel->setRecursiveFilteringEnabled( true ); + + m_treeView->setModel( m_proxyModel ); + m_treeView->setHeaderHidden( true ); + m_treeView->setDragEnabled( true ); + m_treeView->setDragDropMode( QAbstractItemView::DragOnly ); + m_treeView->setEditTriggers( QAbstractItemView::NoEditTriggers ); + m_treeView->setSelectionMode( QAbstractItemView::SingleSelection ); + + auto* layout = new QVBoxLayout( this ); + layout->setContentsMargins( 0, 0, 0, 0 ); + layout->addWidget( new QLabel( "Tasks", this ) ); + layout->addWidget( m_filter ); + layout->addWidget( m_treeView ); + + connect( m_filter, + &QLineEdit::textChanged, + this, + [this]( const QString& text ) + { + m_proxyModel->setFilterFixedString( text ); + if ( !text.isEmpty() ) m_treeView->expandAll(); + } ); + connect( m_treeView, &QTreeView::doubleClicked, this, &RiuWorkflowTaskPalette::onDoubleClicked ); + + reload(); +} + +//-------------------------------------------------------------------------------------------------- +/// Fill the tree from the task catalog +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowTaskPalette::reload() +{ + m_model->clear(); + + const RimWorkflowTaskCatalog& catalog = RimWorkflowTaskCatalog::instance(); + if ( !catalog.isValid() ) + { + auto* item = new QStandardItem( "Task catalog not available" ); + item->setToolTip( catalog.loadError() ); + item->setEnabled( false ); + item->setDragEnabled( false ); + m_model->appendRow( item ); + return; + } + + QMap groups; + for ( const QJsonObject& task : catalog.tasks() ) + { + const QString taskId = task.value( "id" ).toString(); + if ( taskId.isEmpty() ) continue; + + const QString group = groupName( taskId ); + if ( !groups.contains( group ) ) + { + auto* groupItem = new QStandardItem( group ); + groupItem->setData( group, searchTextRole ); + groupItem->setDragEnabled( false ); + groupItem->setSelectable( false ); + m_model->appendRow( groupItem ); + groups.insert( group, groupItem ); + } + + const QString name = task.value( "name" ).toString( taskId.mid( taskId.lastIndexOf( '.' ) + 1 ) ); + const QString description = task.value( "description" ).toString(); + + auto* item = new QStandardItem( name ); + item->setData( taskId, taskIdRole ); + item->setData( QStringList{ taskId, name, description }.join( "\n" ), searchTextRole ); + item->setToolTip( QString( "%1 (%2)

%3

Inputs:\n%4\nOutputs:\n%5
" ) + .arg( name.toHtmlEscaped(), + taskId.toHtmlEscaped(), + description.toHtmlEscaped(), + portSummary( RimWorkflowPortCompatibility::inputPorts( task ) ).toHtmlEscaped(), + portSummary( RimWorkflowPortCompatibility::outputPorts( task ) ).toHtmlEscaped() ) ); + item->setDragEnabled( true ); + groups.value( group )->appendRow( item ); + } + + m_treeView->expandAll(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowTaskPalette::onDoubleClicked( const QModelIndex& index ) +{ + const QString taskId = index.data( taskIdRole ).toString(); + if ( !taskId.isEmpty() ) emit taskActivated( taskId ); +} diff --git a/ApplicationLibCode/UserInterface/RiuWorkflowTaskPalette.h b/ApplicationLibCode/UserInterface/RiuWorkflowTaskPalette.h new file mode 100644 index 0000000000..ec89e64652 --- /dev/null +++ b/ApplicationLibCode/UserInterface/RiuWorkflowTaskPalette.h @@ -0,0 +1,51 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// at for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include + +class QLineEdit; +class QStandardItemModel; +class QSortFilterProxyModel; +class QTreeView; +class QModelIndex; + +//================================================================================================== +/// The task types published by installed packages, grouped by package. Tasks are added to the +/// workflow by dragging them onto the graph or by double-clicking. +//================================================================================================== +class RiuWorkflowTaskPalette : public QWidget +{ + Q_OBJECT + +public: + explicit RiuWorkflowTaskPalette( QWidget* parent = nullptr ); + + void reload(); + +signals: + void taskActivated( const QString& taskId ); + +private: + void onDoubleClicked( const QModelIndex& index ); + +private: + QLineEdit* m_filter; + QTreeView* m_treeView; + QStandardItemModel* m_model; + QSortFilterProxyModel* m_proxyModel; +}; diff --git a/ApplicationLibCode/UserInterface/RiuWorkflowValidationRunner.cpp b/ApplicationLibCode/UserInterface/RiuWorkflowValidationRunner.cpp new file mode 100644 index 0000000000..5d7c91a0ef --- /dev/null +++ b/ApplicationLibCode/UserInterface/RiuWorkflowValidationRunner.cpp @@ -0,0 +1,206 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// 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 "RiuWorkflowValidationRunner.h" + +#include "Workflow/RimWorkflow.h" +#include "Workflow/RimWorkflowHelperProcess.h" +#include "Workflow/RimWorkflowValidationTools.h" + +#include "RiaLogging.h" + +#include +#include + +namespace +{ +RimWorkflowIssue issueWithMessage( const QString& message ) +{ + RimWorkflowIssue issue; + issue.message = message; + return issue; +} +} // namespace + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RiuWorkflowValidationRunner::RiuWorkflowValidationRunner( QObject* parent ) + : QObject( parent ) + , m_debounceTimer( new QTimer( this ) ) + , m_watchdogTimer( new QTimer( this ) ) +{ + m_debounceTimer->setSingleShot( true ); + m_debounceTimer->setInterval( debounceMs ); + connect( m_debounceTimer, &QTimer::timeout, this, &RiuWorkflowValidationRunner::start ); + + m_watchdogTimer->setSingleShot( true ); + m_watchdogTimer->setInterval( watchdogMs ); + connect( m_watchdogTimer, &QTimer::timeout, this, &RiuWorkflowValidationRunner::onTimeout ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RiuWorkflowValidationRunner::~RiuWorkflowValidationRunner() +{ + stopProcess(); +} + +//-------------------------------------------------------------------------------------------------- +/// Validate the current revision of the workflow after a short pause. A run in progress for an +/// older revision is stopped. +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowValidationRunner::schedule( RimWorkflow* workflow ) +{ + if ( !workflow || !workflow->isEditable() || workflow->isValidationCurrent() ) + { + if ( workflow == m_workflow.p() || !workflow ) m_debounceTimer->stop(); + return; + } + + m_workflow = workflow; + if ( m_process && ( m_runningWorkflow.p() != workflow || m_runningRevision != workflow->revision() ) ) stopProcess(); + m_debounceTimer->start(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowValidationRunner::cancel() +{ + m_debounceTimer->stop(); + stopProcess(); + m_workflow = nullptr; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RiuWorkflowValidationRunner::isRunning() const +{ + return m_process != nullptr || m_debounceTimer->isActive(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowValidationRunner::start() +{ + RimWorkflow* workflow = m_workflow.p(); + if ( !workflow || !workflow->isEditable() || workflow->isValidationCurrent() ) return; + if ( m_process && m_runningWorkflow.p() == workflow && m_runningRevision == workflow->revision() ) return; + stopProcess(); + + const QString python = RimWorkflow::findPythonExecutable(); + auto editDirectory = workflow->editDirectory(); + if ( python.isEmpty() || !editDirectory ) + { + const QString message = python.isEmpty() ? QString( "No usable Python interpreter found" ) : editDirectory.error(); + workflow->setValidationResult( workflow->revision(), { issueWithMessage( message ) }, false ); + emit validationFinished( workflow ); + return; + } + + m_runningWorkflow = workflow; + m_runningRevision = workflow->revision(); + + m_process = new QProcess( this ); + m_process->setProcessEnvironment( RimWorkflowHelperProcess::environment( workflow->sourceDirectory() ) ); + connect( m_process, &QProcess::finished, this, &RiuWorkflowValidationRunner::onFinished ); + connect( m_process, + &QProcess::errorOccurred, + this, + [this]( QProcess::ProcessError error ) + { + if ( error == QProcess::FailedToStart ) onFinished(); + } ); + + m_process->start( python, RimWorkflowHelperProcess::helperArguments( { "save", "--out-dir", editDirectory.value(), "--describe" } ) ); + m_process->write( workflow->definitionJsonForSave() ); + m_process->closeWriteChannel(); + m_watchdogTimer->start(); + emit validationStarted(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowValidationRunner::onFinished() +{ + if ( !m_process ) return; + m_watchdogTimer->stop(); + + QProcess* process = m_process; + const int exitCode = process->exitStatus() == QProcess::NormalExit && process->error() != QProcess::FailedToStart ? process->exitCode() + : -1; + const auto output = RimWorkflowHelperProcess::parseOutput( exitCode, process->readAllStandardOutput(), process->readAllStandardError() ); + m_process = nullptr; + process->deleteLater(); + + RimWorkflow* workflow = m_runningWorkflow.p(); + const int revision = m_runningRevision; + m_runningWorkflow = nullptr; + m_runningRevision = -1; + if ( !workflow || workflow->revision() != revision ) return; + + if ( output ) + { + const auto validation = RimWorkflowValidationTools::issuesFromHelperResult( output.value(), workflow->definition().nodeNames() ); + workflow->setValidationResult( revision, validation.issues, validation.describeSucceeded ); + if ( output->value( "status" ).toString() != "invalid" ) workflow->setEditDirectoryRevision( revision ); + } + else + { + RiaLogging::debug( QString( "Workflow validation failed: %1" ).arg( output.error() ).toStdString() ); + workflow->setValidationResult( revision, { issueWithMessage( output.error() ) }, false ); + } + emit validationFinished( workflow ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowValidationRunner::onTimeout() +{ + RimWorkflow* workflow = m_runningWorkflow.p(); + const int revision = m_runningRevision; + stopProcess(); + if ( workflow && workflow->revision() == revision ) + { + workflow->setValidationResult( revision, { issueWithMessage( "Validation timed out" ) }, false ); + emit validationFinished( workflow ); + } +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowValidationRunner::stopProcess() +{ + m_watchdogTimer->stop(); + if ( m_process ) + { + QProcess* process = m_process; + m_process = nullptr; + process->disconnect( this ); + process->kill(); + process->waitForFinished( 1000 ); + process->deleteLater(); + } + m_runningWorkflow = nullptr; + m_runningRevision = -1; +} diff --git a/ApplicationLibCode/UserInterface/RiuWorkflowValidationRunner.h b/ApplicationLibCode/UserInterface/RiuWorkflowValidationRunner.h new file mode 100644 index 0000000000..48949730c3 --- /dev/null +++ b/ApplicationLibCode/UserInterface/RiuWorkflowValidationRunner.h @@ -0,0 +1,65 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// at for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "cafPdmPointer.h" + +#include +#include + +class QProcess; +class QTimer; +class RimWorkflow; + +//================================================================================================== +/// Checks the edited workflow with `taskmaestro workflow describe` in the background: a short +/// pause after the last edit, then `taskmaestro_helper save --describe` into the workflow's edit +/// folder. Results for an older revision are dropped. +//================================================================================================== +class RiuWorkflowValidationRunner : public QObject +{ + Q_OBJECT + +public: + static constexpr int debounceMs = 400; + static constexpr int watchdogMs = 30000; + + explicit RiuWorkflowValidationRunner( QObject* parent = nullptr ); + ~RiuWorkflowValidationRunner() override; + + void schedule( RimWorkflow* workflow ); + void cancel(); + bool isRunning() const; + +signals: + void validationStarted(); + void validationFinished( RimWorkflow* workflow ); + +private: + void start(); + void onFinished(); + void onTimeout(); + void stopProcess(); + +private: + QTimer* m_debounceTimer; + QTimer* m_watchdogTimer; + QPointer m_process; + caf::PdmPointer m_workflow; + caf::PdmPointer m_runningWorkflow; + int m_runningRevision = -1; +}; diff --git a/GrpcInterface/Python/rips/taskmaestro_helper/__init__.py b/GrpcInterface/Python/rips/taskmaestro_helper/__init__.py index cc4a3947c6..d961e18f0f 100644 --- a/GrpcInterface/Python/rips/taskmaestro_helper/__init__.py +++ b/GrpcInterface/Python/rips/taskmaestro_helper/__init__.py @@ -1,6 +1,10 @@ """Bridge between ResInsight and the taskmaestro workflow library. -ResInsight invokes this package as a subprocess to execute workflows against -a running ResInsight. Workflow schemas are read with the taskmaestro CLI -(`python -m taskmaestro workflow describe`). +ResInsight invokes this package as a subprocess (``python -m +rips.taskmaestro_helper ``) to: + +- list installed tasks and workflows (``catalog``), +- convert workflow folders and registered workflows to editable workflow + definitions (``load``, ``export-registered``) and back to YAML (``save``), +- execute workflows against a running ResInsight (``run``). """ diff --git a/GrpcInterface/Python/rips/taskmaestro_helper/__main__.py b/GrpcInterface/Python/rips/taskmaestro_helper/__main__.py index 5396a160bd..52b897dbc4 100644 --- a/GrpcInterface/Python/rips/taskmaestro_helper/__main__.py +++ b/GrpcInterface/Python/rips/taskmaestro_helper/__main__.py @@ -1,28 +1,113 @@ -"""Command-line entry point: `python -m rips.taskmaestro_helper `.""" +"""Command-line entry point: `python -m rips.taskmaestro_helper `. + +Subcommands other than ``run`` print exactly one JSON document to stdout. Any +output written while importing task modules is redirected to stderr. On +failure they exit with code 2 and print +``{"status": "invalid", "error": {"message", "task", "field"}}``. +""" from __future__ import annotations +import json import sys +from collections.abc import Callable +from contextlib import redirect_stdout +from typing import Any + +from ._compat import HelperError + +USAGE = ( + "usage: python -m rips.taskmaestro_helper " + "{catalog,load,save,export-registered,run} ..." +) -def main() -> int: - if len(sys.argv) < 2: +def _catalog(argv: list[str]) -> dict[str, Any]: + from .catalog import catalog_main + + return catalog_main(argv) + + +def _load(argv: list[str]) -> dict[str, Any]: + from .definition import load_main + + return load_main(argv) + + +def _save(argv: list[str]) -> dict[str, Any]: + from .definition import save_main + + return save_main(argv, sys.stdin.read()) + + +def _export_registered(argv: list[str]) -> dict[str, Any]: + from .catalog import export_registered_main + + return export_registered_main(argv) + + +JSON_COMMANDS: dict[str, Callable[[list[str]], dict[str, Any]]] = { + "catalog": _catalog, + "load": _load, + "save": _save, + "export-registered": _export_registered, +} + + +def _invalid(error: dict[str, Any]) -> str: + return json.dumps({"status": "invalid", "error": error}) + + +def run_json_command( + command: Callable[[list[str]], dict[str, Any]], argv: list[str] +) -> int: + from ._compat import require_taskmaestro + + stdout = sys.stdout + try: + with redirect_stdout(sys.stderr): + require_taskmaestro() + result = command(argv) + except HelperError as exc: + print(_invalid(exc.to_json()), file=stdout) + return 2 + except SystemExit as exc: # argparse errors print( - "usage: python -m rips.taskmaestro_helper run ...", - file=sys.stderr, + _invalid( + {"message": f"Invalid arguments: {exc}", "task": None, "field": None} + ), + file=stdout, ) return 2 + except Exception as exc: + print( + _invalid( + {"message": f"{type(exc).__name__}: {exc}", "task": None, "field": None} + ), + file=stdout, + ) + return 2 + print(json.dumps(result), file=stdout) + return 0 - subcommand = sys.argv[1] - rest = sys.argv[2:] +def main(argv: list[str] | None = None) -> int: + args = sys.argv[1:] if argv is None else argv + if not args: + print(USAGE, file=sys.stderr) + return 2 + + subcommand, rest = args[0], args[1:] if subcommand == "run": from . import run return run.main(rest) - print(f"unknown subcommand: {subcommand}", file=sys.stderr) - return 2 + command = JSON_COMMANDS.get(subcommand) + if command is None: + print(f"unknown subcommand: {subcommand}\n{USAGE}", file=sys.stderr) + return 2 + return run_json_command(command, rest) if __name__ == "__main__": diff --git a/GrpcInterface/Python/rips/taskmaestro_helper/_compat.py b/GrpcInterface/Python/rips/taskmaestro_helper/_compat.py new file mode 100644 index 0000000000..7c79b7b8a2 --- /dev/null +++ b/GrpcInterface/Python/rips/taskmaestro_helper/_compat.py @@ -0,0 +1,56 @@ +"""Version guard and shared error type for the taskmaestro helper. + +The helper relies on a few taskmaestro internals (YAML loader models, +``import_class`` and the CLI describe implementation), so it only supports the +0.3 series. +""" + +from __future__ import annotations + +MIN_TASKMAESTRO_VERSION = (0, 3) +MAX_TASKMAESTRO_VERSION = (0, 4) + + +class HelperError(Exception): + """An error reported to ResInsight as ``{"status": "invalid", "error": ...}``.""" + + def __init__( + self, message: str, *, task: str | None = None, field: str | None = None + ) -> None: + super().__init__(message) + self.message = message + self.task = task + self.field = field + + def to_json(self) -> dict[str, str | None]: + return {"message": self.message, "task": self.task, "field": self.field} + + +def _version_tuple(version: str) -> tuple[int, ...]: + parts: list[int] = [] + for part in version.split("."): + digits = "".join(ch for ch in part if ch.isdigit()) + if not digits: + break + parts.append(int(digits)) + return tuple(parts) + + +def taskmaestro_version() -> str: + import taskmaestro + + return str(getattr(taskmaestro, "__version__", "0")) + + +def require_taskmaestro() -> None: + """Raise HelperError unless a supported taskmaestro version is installed.""" + try: + version = taskmaestro_version() + except ImportError as exc: + raise HelperError(f"taskmaestro is not installed: {exc}") from exc + parsed = _version_tuple(version) + if parsed < MIN_TASKMAESTRO_VERSION or parsed >= MAX_TASKMAESTRO_VERSION: + raise HelperError( + f"taskmaestro {version} is not supported; " + "install taskmaestro>=0.3,<0.4 to edit workflows" + ) diff --git a/GrpcInterface/Python/rips/taskmaestro_helper/catalog.py b/GrpcInterface/Python/rips/taskmaestro_helper/catalog.py new file mode 100644 index 0000000000..57e641e59c --- /dev/null +++ b/GrpcInterface/Python/rips/taskmaestro_helper/catalog.py @@ -0,0 +1,170 @@ +"""Catalog of installed taskmaestro tasks and workflows. + +Every entry point is loaded on its own, so one broken plugin is reported in +``errors`` while the rest of the catalog still loads. +""" + +from __future__ import annotations + +from typing import Any + +from ._compat import HelperError, taskmaestro_version +from .definition import definition_from_workflow +from .schema import task_descriptor + +CATALOG_FORMAT = 1 + + +def _entry_points(group: str) -> list[Any]: + # Looked up through the module so tests can monkeypatch discovery.entry_points. + from taskmaestro import discovery + + return sorted(discovery.entry_points(group=group), key=lambda ep: ep.name) + + +def _error( + kind: str, entry_id: str, value: str, exc: Exception | str +) -> dict[str, Any]: + return {"kind": kind, "id": entry_id, "entry_point": value, "message": str(exc)} + + +def load_registered_tasks() -> tuple[dict[str, type[Any]], list[dict[str, Any]]]: + """Load every task entry point. Returns ``(tasks by id, errors)``.""" + from taskmaestro.discovery import TASK_ENTRY_POINT_GROUP + from taskmaestro.task import Task + + tasks: dict[str, type[Any]] = {} + errors: list[dict[str, Any]] = [] + for entry_point in _entry_points(TASK_ENTRY_POINT_GROUP): + if entry_point.name in tasks: + errors.append( + _error("task", entry_point.name, entry_point.value, "Duplicate task id") + ) + continue + try: + cls = entry_point.load() + except Exception as exc: + errors.append(_error("task", entry_point.name, entry_point.value, exc)) + continue + if not (isinstance(cls, type) and issubclass(cls, Task)): + errors.append( + _error( + "task", + entry_point.name, + entry_point.value, + "The entry point is not a Task subclass", + ) + ) + continue + tasks[entry_point.name] = cls + return tasks, errors + + +def class_ids(tasks: dict[str, type[Any]]) -> dict[type[Any], str]: + """Map task classes back to their (first) entry-point id.""" + result: dict[type[Any], str] = {} + for task_id, cls in sorted(tasks.items()): + result.setdefault(cls, task_id) + return result + + +def _workflow_entry( + workflow_id: str, workflow: Any, ids: dict[type[Any], str] +) -> dict[str, Any]: + definition = definition_from_workflow(workflow, workflow_id, ids, describe=True) + return { + "id": workflow_id, + "name": workflow.name, + "definition": definition, + "describe": definition["describe"], + "editable": definition["editable"], + "readonly_reasons": definition["readonly_reasons"], + } + + +def build_catalog(*, include_workflows: bool = True) -> dict[str, Any]: + from taskmaestro.discovery import WORKFLOW_ENTRY_POINT_GROUP + from taskmaestro.workflow import Workflow + + tasks, errors = load_registered_tasks() + descriptors: list[dict[str, Any]] = [] + for task_id, cls in sorted(tasks.items()): + try: + descriptors.append(task_descriptor(task_id, cls)) + except Exception as exc: + errors.append(_error("task", task_id, "", f"Cannot describe task: {exc}")) + + workflows: list[dict[str, Any]] = [] + if include_workflows: + ids = class_ids(tasks) + seen: set[str] = set() + for entry_point in _entry_points(WORKFLOW_ENTRY_POINT_GROUP): + if entry_point.name in seen: + errors.append( + _error( + "workflow", + entry_point.name, + entry_point.value, + "Duplicate workflow id", + ) + ) + continue + seen.add(entry_point.name) + try: + workflow = entry_point.load() + if not isinstance(workflow, Workflow): + raise TypeError("The entry point is not a Workflow") + workflows.append(_workflow_entry(entry_point.name, workflow, ids)) + except Exception as exc: + errors.append( + _error("workflow", entry_point.name, entry_point.value, exc) + ) + + return { + "format": CATALOG_FORMAT, + "status": "ok", + "taskmaestro_version": taskmaestro_version(), + "tasks": descriptors, + "workflows": workflows, + "errors": errors, + } + + +def export_registered(workflow_id: str, *, describe: bool = False) -> dict[str, Any]: + from taskmaestro.discovery import get_registered_workflow + from taskmaestro.exceptions import PluginLoadError + + try: + workflow = get_registered_workflow(workflow_id) + except PluginLoadError as exc: + raise HelperError(str(exc)) from exc + tasks, _errors = load_registered_tasks() + return definition_from_workflow( + workflow, workflow_id, class_ids(tasks), describe=describe + ) + + +def catalog_main(argv: list[str]) -> dict[str, Any]: + import argparse + + parser = argparse.ArgumentParser( + prog="catalog", description="List installed taskmaestro tasks and workflows." + ) + parser.add_argument("--no-workflows", action="store_true") + args = parser.parse_args(argv) + return build_catalog(include_workflows=not args.no_workflows) + + +def export_registered_main(argv: list[str]) -> dict[str, Any]: + import argparse + + parser = argparse.ArgumentParser( + prog="export-registered", + description="Convert a registered workflow to a workflow definition.", + ) + parser.add_argument("workflow_id") + parser.add_argument("--describe", action="store_true") + args = parser.parse_args(argv) + result = export_registered(args.workflow_id, describe=args.describe) + result["status"] = "ok" + return result diff --git a/GrpcInterface/Python/rips/taskmaestro_helper/definition.py b/GrpcInterface/Python/rips/taskmaestro_helper/definition.py new file mode 100644 index 0000000000..c511d3ad01 --- /dev/null +++ b/GrpcInterface/Python/rips/taskmaestro_helper/definition.py @@ -0,0 +1,719 @@ +"""Workflow definitions exchanged between ResInsight and taskmaestro YAML files. + +A *definition* is a JSON document that describes a workflow as a plain graph: +task instances (``nodes``), connections between task outputs and inputs +(``edges``) and per-task configuration values (``inputs``). ResInsight edits +definitions; this module converts them from and to the standard +``workflow.yaml``/``input.yaml`` pair and from registered workflows. +""" + +from __future__ import annotations + +import os +import shutil +import sys +import tempfile +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path +from typing import Any + +import yaml + +from ._compat import HelperError +from .describe import describe_workflow, describe_yaml +from .refs import REF_MARKER +from .schema import task_descriptor + +DEFINITION_FORMAT = 1 +WORKFLOW_FILE = "workflow.yaml" +INPUT_FILE = "input.yaml" +BACKUP_SUFFIX = ".bak" + + +# --- Small helpers --- + + +def _edge( + upstream: str, output: str | None, downstream: str, input_field: str | None +) -> dict[str, Any]: + return {"from": upstream, "output": output, "to": downstream, "input": input_field} + + +def _empty_definition(name: str) -> dict[str, Any]: + return { + "format": DEFINITION_FORMAT, + "name": name, + "result_task": None, + "editable": True, + "readonly_reasons": [], + "warnings": [], + "source": {"workflow_yaml": None, "input_yaml": None, "registered_id": None}, + "header_comment": "", + "passthrough": {}, + "nodes": [], + "edges": [], + "inputs": {}, + "task_types": {}, + "describe": None, + "describe_error": None, + } + + +def header_comment(text: str) -> str: + """Return the comment block at the top of a YAML file.""" + lines: list[str] = [] + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("#"): + lines.append(line.rstrip()) + elif stripped == "" and lines: + lines.append("") + elif stripped != "": + break + while lines and lines[-1] == "": + lines.pop() + return "\n".join(lines) + + +@contextmanager +def workflow_imports(directory: Path) -> Iterator[None]: + """Make task modules next to a workflow file importable while loading.""" + original_path = sys.path.copy() + sys.path.insert(0, str(directory.resolve())) + try: + yield + finally: + sys.path[:] = original_path + + +def _jsonable(value: Any, path: str) -> Any: + from taskmaestro.cli import _jsonable as taskmaestro_jsonable + + try: + return taskmaestro_jsonable(value, path) + except (TypeError, ValueError) as exc: + raise HelperError(str(exc)) from exc + + +def _yaml_load(text: str, what: str) -> Any: + from taskmaestro.yaml_config import _yaml_load as taskmaestro_yaml_load + + try: + return taskmaestro_yaml_load(text) + except yaml.YAMLError as exc: + raise HelperError(f"{what} parse error: {exc}") from exc + + +def strip_refs(value: Any) -> Any: + """Remove ResInsight object references, which are only valid for one run.""" + if isinstance(value, dict): + return { + key: strip_refs(item) + for key, item in value.items() + if not (isinstance(item, dict) and REF_MARKER in item) + } + if isinstance(value, list): + return [ + strip_refs(item) + for item in value + if not (isinstance(item, dict) and REF_MARKER in item) + ] + return value + + +def _is_identifier(name: str) -> bool: + return isinstance(name, str) and name.isidentifier() + + +# --- Loading YAML --- + + +class _TaskResolver: + """Resolve the ``task:`` strings of a YAML file like taskmaestro's loader.""" + + def __init__(self) -> None: + from taskmaestro.discovery import registered_task_names + + self.installed = registered_task_names() + + def resolve(self, task_id: str) -> type[Any]: + from taskmaestro.discovery import get_registered_task + from taskmaestro.exceptions import ConfigLoadError, PluginLoadError + from taskmaestro.task import Task + from taskmaestro.yaml_config import import_class + + try: + if task_id in self.installed: + cls = get_registered_task(task_id) + else: + cls = import_class(task_id) + except (ConfigLoadError, PluginLoadError) as exc: + raise HelperError(str(exc)) from exc + if not (isinstance(cls, type) and issubclass(cls, Task)): + raise HelperError(f"'{task_id}' is not a Task subclass") + return cls + + +def _inner_workflow_name(base_dir: Path, workflow_path: str) -> str: + try: + raw = yaml.safe_load((base_dir / workflow_path).read_text()) + return str(raw["workflow"]["name"]) + except Exception: + return Path(workflow_path).stem + + +def _add_task_type(definition: dict[str, Any], task_id: str, cls: type[Any]) -> None: + if task_id in definition["task_types"]: + return + try: + definition["task_types"][task_id] = task_descriptor(task_id, cls) + except Exception as exc: + definition["readonly_reasons"].append( + f"Cannot describe task '{task_id}': {exc}" + ) + + +def _load_nodes( + definition: dict[str, Any], config: Any, base_dir: Path +) -> dict[str, set[str]]: + """Create one node per YAML entry and return the reference lookup.""" + resolver = _TaskResolver() + candidates: dict[str, set[str]] = {} + for entry in config.workflow.tasks: + node: dict[str, Any] = {"name": "", "task": entry.task, "config_fields": []} + if entry.workflow: + key = entry.workflow + name = entry.name or _inner_workflow_name(base_dir, entry.workflow) + node["workflow"] = entry.workflow + definition["readonly_reasons"].append( + f"Task '{name}' is a nested workflow ('{entry.workflow}')" + ) + else: + key = entry.task + try: + cls = resolver.resolve(entry.task) + except HelperError as exc: + cls = None + definition["readonly_reasons"].append( + f"Cannot load task '{entry.task}': {exc.message}" + ) + name = entry.name or (cls.name if cls is not None else entry.task) + if cls is not None: + _add_task_type(definition, entry.task, cls) + if entry.map is not None: + node["map"] = entry.map.model_dump() + definition["readonly_reasons"].append(f"Task '{name}' is mapped ('map:')") + if any(existing["name"] == name for existing in definition["nodes"]): + raise HelperError(f"Duplicate task name '{name}'", task=name) + node["name"] = name + node["config_fields"] = list(entry.config_fields or []) + node["_declared_config"] = entry.config_fields is not None + definition["nodes"].append(node) + candidates.setdefault(key, set()).add(name) + if entry.name: + candidates.setdefault(entry.name, set()).add(name) + return candidates + + +def _resolve_reference(candidates: dict[str, set[str]], ref: str, context: str) -> str: + names = candidates.get(ref) + where = f" for task '{context}'" if context else "" + if names is None: + raise HelperError(f"Dependency '{ref}'{where} not found", task=context or None) + if len(names) > 1: + raise HelperError( + f"Dependency '{ref}'{where} is ambiguous; it matches {sorted(names)}. " + "Use the instance name.", + task=context or None, + ) + return next(iter(names)) + + +def _field_reference(raw: Any, task: str, field: str | None) -> tuple[str, str | None]: + if isinstance(raw, str): + return raw, None + if ( + isinstance(raw, list) + and len(raw) == 2 + and all(isinstance(item, str) for item in raw) + ): + return raw[0], raw[1] + where = f" for field '{field}'" if field else "" + raise HelperError( + f"Invalid dependency {raw!r}{where} on task '{task}'", task=task, field=field + ) + + +def _load_edges( + definition: dict[str, Any], config: Any, candidates: dict[str, set[str]] +) -> None: + entries = config.workflow.tasks + nodes = definition["nodes"] + linear = all(entry.depends_on is None and entry.map is None for entry in entries) + if linear: + for previous, node in zip(nodes, nodes[1:]): + definition["edges"].append( + _edge(previous["name"], None, node["name"], None) + ) + if len(nodes) > 1: + definition["warnings"].append( + "The workflow has no 'depends_on' entries; tasks were chained in " + "file order and the dependencies will be saved explicitly" + ) + return + + for entry, node in zip(entries, nodes): + name = node["name"] + deps = entry.depends_on + if deps is None: + continue + if isinstance(deps, dict): + for field, raw in deps.items(): + if isinstance(raw, dict) and set(raw) == {"collect"}: + definition["readonly_reasons"].append( + f"Task '{name}' collects outputs into '{field}' ('collect:')" + ) + continue + ref, output = _field_reference(raw, name, field) + upstream = _resolve_reference(candidates, ref, name) + definition["edges"].append(_edge(upstream, output, name, field)) + continue + ref, output = _field_reference(deps, name, None) + upstream = _resolve_reference(candidates, ref, name) + definition["edges"].append(_edge(upstream, output, name, None)) + + +def _load_inputs(definition: dict[str, Any], input_path: Path | None) -> None: + raw_input: Any = {} + if input_path is not None and input_path.exists(): + raw_input = _yaml_load(input_path.read_text(), "Input YAML") or {} + if not isinstance(raw_input, dict): + raise HelperError("Input YAML file must contain a mapping") + + nodes = {node["name"]: node for node in definition["nodes"]} + for key, values in raw_input.items(): + if key not in nodes: + definition["warnings"].append( + f"Dropped input values for unknown task '{key}'" + ) + continue + if values is None: + values = {} + if not isinstance(values, dict): + raise HelperError( + f"Input value for task '{key}' must be a mapping", task=key + ) + definition["inputs"][key] = _jsonable(values, str(key)) + + for node in nodes.values(): + values = definition["inputs"].setdefault(node["name"], {}) + map_source = node.get("map", {}).get("over") if node.get("map") else None + if not node.pop("_declared_config"): + # taskmaestro uses the input keys when config_fields is omitted. + node["config_fields"] = [field for field in values if field != map_source] + wired = { + edge["input"] + for edge in definition["edges"] + if edge["to"] == node["name"] and edge["input"] is not None + } + for field in sorted(wired & set(node["config_fields"])): + node["config_fields"].remove(field) + if field in values: + definition["warnings"].append( + f"Input '{node['name']}.{field}' is connected to an upstream task; " + "its configured value was dropped" + ) + del values[field] + if definition["editable"]: + for field in [f for f in values if f not in node["config_fields"]]: + if field == map_source: + continue + definition["warnings"].append( + f"Dropped value for '{node['name']}.{field}', which is not a " + "configuration field" + ) + del values[field] + definition["inputs"] = {name: definition["inputs"][name] for name in nodes} + + +def load_definition(workflow_dir: Path, *, describe: bool = False) -> dict[str, Any]: + """Load ``workflow.yaml`` (and ``input.yaml``) from a workflow folder.""" + from pydantic import ValidationError + from taskmaestro.yaml_config import YamlWorkflowConfig + + workflow_dir = workflow_dir.resolve() + workflow_path = workflow_dir / WORKFLOW_FILE + input_path = workflow_dir / INPUT_FILE + try: + text = workflow_path.read_text() + except OSError as exc: + raise HelperError(f"Cannot read '{workflow_path}': {exc}") from exc + raw = _yaml_load(text, "YAML") + if not isinstance(raw, dict): + raise HelperError("YAML file must contain a mapping at top level") + try: + config = YamlWorkflowConfig.model_validate(raw) + except ValidationError as exc: + raise HelperError(f"YAML schema validation error: {exc}") from exc + + definition = _empty_definition(config.workflow.name) + definition["source"] = { + "workflow_yaml": str(workflow_path), + "input_yaml": str(input_path) if input_path.exists() else None, + "registered_id": None, + } + definition["header_comment"] = header_comment(text) + definition["passthrough"] = { + key: _jsonable(value, str(key)) + for key, value in raw.items() + if key != "workflow" + } + + with workflow_imports(workflow_dir): + candidates = _load_nodes(definition, config, workflow_dir) + _load_edges(definition, config, candidates) + if config.workflow.result_task: + definition["result_task"] = _resolve_reference( + candidates, config.workflow.result_task, "" + ) + definition["editable"] = not definition["readonly_reasons"] + _load_inputs(definition, input_path) + + if describe: + definition["describe"], definition["describe_error"] = describe_yaml( + workflow_path, input_path if input_path.exists() else None + ) + return definition + + +# --- Registered workflows --- + + +def _class_task_id(cls: type[Any], class_ids: dict[type[Any], str]) -> str | None: + """Return a task identifier that resolves back to ``cls``, if any.""" + from taskmaestro.exceptions import ConfigLoadError + from taskmaestro.yaml_config import import_class + + if cls in class_ids: + return class_ids[cls] + if "" in cls.__qualname__: + return None + path = f"{cls.__module__}.{cls.__qualname__}" + try: + return path if import_class(path) is cls else None + except ConfigLoadError: + return None + + +def definition_from_workflow( + workflow: Any, + registered_id: str | None, + class_ids: dict[type[Any], str], + *, + describe: bool = False, +) -> dict[str, Any]: + """Convert an in-memory workflow to a definition. + + ``class_ids`` maps registered task classes to their entry-point names. + """ + from taskmaestro.dependencies import CollectionRef + + definition = _empty_definition(workflow.name) + definition["source"]["registered_id"] = registered_id + reasons = definition["readonly_reasons"] + + has_dependents: set[str] = set() + for name, cls in workflow.topological_order(): + node: dict[str, Any] = { + "name": name, + "task": None, + "config_fields": sorted(workflow.get_config_fields(name)), + } + if hasattr(cls, "_inner_workflow"): + reasons.append(f"Task '{name}' is a nested workflow") + else: + task_id = _class_task_id(cls, class_ids) + if task_id is None: + reasons.append( + f"Task '{name}' uses class '{cls.__module__}.{cls.__qualname__}', " + "which cannot be referenced from YAML" + ) + else: + node["task"] = task_id + _add_task_type(definition, task_id, cls) + if workflow.get_task_map(name) is not None: + reasons.append(f"Task '{name}' is mapped") + definition["nodes"].append(node) + definition["inputs"][name] = {} + + deps = workflow.get_dependencies(name) + if deps is None: + continue + if isinstance(deps, str): + definition["edges"].append(_edge(deps, None, name, None)) + elif isinstance(deps, tuple): + definition["edges"].append(_edge(deps[0], deps[1], name, None)) + else: + for field, ref in deps.items(): + if isinstance(ref, CollectionRef): + reasons.append(f"Task '{name}' collects outputs into '{field}'") + has_dependents.update(item.task_name for item in ref.output_refs()) + elif isinstance(ref, tuple): + definition["edges"].append(_edge(ref[0], ref[1], name, field)) + else: + definition["edges"].append(_edge(ref, None, name, field)) + + has_dependents.update(edge["from"] for edge in definition["edges"]) + sinks = [ + node["name"] + for node in definition["nodes"] + if node["name"] not in has_dependents + ] + if sinks != [workflow.result_task_name]: + definition["result_task"] = workflow.result_task_name + definition["editable"] = not reasons + if describe: + try: + definition["describe"] = describe_workflow(workflow) + except Exception as exc: + definition["describe_error"] = { + "code": "describe_error", + "message": str(exc), + } + return definition + + +# --- Saving YAML --- + + +class _FlowList(list[Any]): + """A list written in YAML flow style (``[a, b]``).""" + + +class _Dumper(yaml.SafeDumper): + def increase_indent(self, flow: bool = False, indentless: bool = False) -> Any: + return super().increase_indent(flow, False) + + +def _represent_flow_list(dumper: yaml.SafeDumper, data: _FlowList) -> Any: + return dumper.represent_sequence("tag:yaml.org,2002:seq", data, flow_style=True) + + +_Dumper.add_representer(_FlowList, _represent_flow_list) + + +def _dump_yaml(data: Any) -> str: + return yaml.dump( + data, + Dumper=_Dumper, + sort_keys=False, + default_flow_style=False, + allow_unicode=True, + width=100, + ) + + +def _reference(upstream: str, output: str | None) -> Any: + return upstream if output is None else _FlowList([upstream, output]) + + +def _check_saveable(definition: dict[str, Any]) -> None: + if not definition.get("editable", True): + reasons = "; ".join(definition.get("readonly_reasons", [])) + raise HelperError(f"The workflow is read-only and cannot be saved: {reasons}") + if not _is_identifier(definition.get("name", "")): + raise HelperError(f"Invalid workflow name '{definition.get('name', '')}'") + nodes = definition.get("nodes", []) + if not nodes: + raise HelperError("The workflow has no tasks") + names: set[str] = set() + for node in nodes: + name = node.get("name") + if not _is_identifier(name): + raise HelperError(f"Invalid task name '{name}'", task=name) + if name in names: + raise HelperError(f"Duplicate task name '{name}'", task=name) + if not node.get("task"): + raise HelperError(f"Task '{name}' has no task type", task=name) + names.add(name) + for edge in definition.get("edges", []): + for end in ("from", "to"): + if edge.get(end) not in names: + raise HelperError( + f"Connection refers to unknown task '{edge.get(end)}'" + ) + result_task = definition.get("result_task") + if result_task is not None and result_task not in names: + raise HelperError(f"Unknown result task '{result_task}'") + + +def _dependencies(definition: dict[str, Any], node_name: str) -> Any: + incoming = [edge for edge in definition.get("edges", []) if edge["to"] == node_name] + whole = [edge for edge in incoming if edge.get("input") is None] + fields = [edge for edge in incoming if edge.get("input") is not None] + if whole and fields: + raise HelperError( + f"Task '{node_name}' cannot take its whole input from one task and " + "individual input fields from others", + task=node_name, + ) + if len(whole) > 1: + raise HelperError( + f"Task '{node_name}' has more than one connection to its whole input", + task=node_name, + ) + if whole: + return _reference(whole[0]["from"], whole[0].get("output")) + if not fields: + return None + deps: dict[str, Any] = {} + for edge in fields: + if edge["input"] in deps: + raise HelperError( + f"Input '{node_name}.{edge['input']}' has more than one connection", + task=node_name, + field=edge["input"], + ) + deps[edge["input"]] = _reference(edge["from"], edge.get("output")) + return deps + + +def workflow_document(definition: dict[str, Any]) -> dict[str, Any]: + """Build the ``workflow.yaml`` document for a definition.""" + _check_saveable(definition) + nodes = definition["nodes"] + has_edges = bool(definition.get("edges")) + tasks: list[dict[str, Any]] = [] + for index, node in enumerate(nodes): + entry: dict[str, Any] = {"task": node["task"], "name": node["name"]} + deps = _dependencies(definition, node["name"]) + if deps is None and index == 0 and len(nodes) > 1 and not has_edges: + # Without any depends_on taskmaestro chains the tasks in file order. + deps = {} + if deps is not None: + entry["depends_on"] = deps + if node.get("config_fields"): + entry["config_fields"] = _FlowList(node["config_fields"]) + tasks.append(entry) + + section: dict[str, Any] = {"name": definition["name"]} + if definition.get("result_task"): + section["result_task"] = definition["result_task"] + section["tasks"] = tasks + document: dict[str, Any] = {"workflow": section} + for key, value in (definition.get("passthrough") or {}).items(): + if key != "workflow": + document[key] = value + return document + + +def input_document(definition: dict[str, Any]) -> dict[str, Any]: + """Build the ``input.yaml`` document: one key per task, literal values only.""" + inputs = definition.get("inputs") or {} + document: dict[str, Any] = {} + for node in definition["nodes"]: + values = inputs.get(node["name"]) or {} + config_fields = node.get("config_fields") or [] + document[node["name"]] = strip_refs( + {field: value for field, value in values.items() if field in config_fields} + ) + return document + + +def _write_atomically(path: Path, text: str) -> None: + fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(text) + os.replace(temp_name, path) + except BaseException: + Path(temp_name).unlink(missing_ok=True) + raise + + +def write_definition( + definition: dict[str, Any], + out_dir: Path, + *, + workflow_only: bool = False, + backup: bool = False, +) -> dict[str, Any]: + """Write ``workflow.yaml`` (and ``input.yaml``) for a definition.""" + workflow_text = "\n".join( + _dump_yaml({key: value}) for key, value in workflow_document(definition).items() + ) + comment = (definition.get("header_comment") or "").strip("\n") + if comment: + workflow_text = f"{comment}\n\n{workflow_text}" + input_text = None if workflow_only else _dump_yaml(input_document(definition)) + + out_dir.mkdir(parents=True, exist_ok=True) + workflow_path = out_dir / WORKFLOW_FILE + input_path = out_dir / INPUT_FILE + backups: list[str] = [] + if backup: + for path in (workflow_path, input_path): + backup_path = path.with_name(path.name + BACKUP_SUFFIX) + if path.exists() and not backup_path.exists(): + shutil.copy2(path, backup_path) + backups.append(str(backup_path)) + + _write_atomically(workflow_path, workflow_text) + if input_text is not None: + _write_atomically(input_path, input_text) + return { + "status": "ok", + "workflow_yaml": str(workflow_path), + "input_yaml": str(input_path) if input_text is not None else None, + "backups": backups, + } + + +# --- Command-line entry points --- + + +def load_main(argv: list[str]) -> dict[str, Any]: + import argparse + + parser = argparse.ArgumentParser(prog="load", description="Load a workflow folder.") + parser.add_argument("workflow_dir", type=Path) + parser.add_argument("--describe", action="store_true") + args = parser.parse_args(argv) + result = load_definition(args.workflow_dir, describe=args.describe) + result["status"] = "ok" + return result + + +def save_main(argv: list[str], stdin_text: str) -> dict[str, Any]: + import argparse + import json + + parser = argparse.ArgumentParser( + prog="save", + description="Write a workflow definition (read from stdin) as YAML.", + ) + parser.add_argument("--out-dir", type=Path, required=True) + parser.add_argument("--workflow-only", action="store_true") + parser.add_argument("--describe", action="store_true") + parser.add_argument("--backup", action="store_true") + args = parser.parse_args(argv) + try: + definition = json.loads(stdin_text) + except json.JSONDecodeError as exc: + raise HelperError(f"Invalid definition JSON: {exc}") from exc + if not isinstance(definition, dict): + raise HelperError("The definition must be a JSON object") + + result = write_definition( + definition, + args.out_dir, + workflow_only=args.workflow_only, + backup=args.backup, + ) + if args.describe: + input_path = Path(result["input_yaml"]) if result["input_yaml"] else None + result["describe"], result["describe_error"] = describe_yaml( + Path(result["workflow_yaml"]), input_path + ) + return result diff --git a/GrpcInterface/Python/rips/taskmaestro_helper/describe.py b/GrpcInterface/Python/rips/taskmaestro_helper/describe.py new file mode 100644 index 0000000000..81a21349ba --- /dev/null +++ b/GrpcInterface/Python/rips/taskmaestro_helper/describe.py @@ -0,0 +1,120 @@ +"""Workflow descriptions in the shape of ``taskmaestro workflow describe --json``. + +``describe_yaml`` runs taskmaestro's own describe implementation in-process so +ResInsight gets the authoritative validation result for a YAML workflow, but +with the real error message (the public CLI deliberately hides it). +``describe_workflow`` produces the same document for an in-memory (registered) +workflow, using only the public ``Workflow`` API. +""" + +from __future__ import annotations + +import argparse +import io +import json +from contextlib import redirect_stdout +from dataclasses import asdict +from pathlib import Path +from typing import Any + +from .schema import model_json_schema, python_type_name + + +def _error_from_exception(exc: Exception) -> dict[str, Any]: + from taskmaestro.cli import _error + + return _error("configuration_error", exc, str(exc)) + + +def describe_yaml( + workflow_yaml: Path, input_yaml: Path | None = None +) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: + """Describe a YAML workflow. Returns ``(description, error)``.""" + from taskmaestro import cli + from taskmaestro.exceptions import ConfigLoadError, PluginLoadError + + args = argparse.Namespace( + workflow=str(workflow_yaml), + input=str(input_yaml) if input_yaml is not None else None, + json=True, + ) + captured = io.StringIO() + try: + with redirect_stdout(captured): + cli._workflow_describe(args) + except (ConfigLoadError, PluginLoadError) as exc: + return None, _error_from_exception(exc) + except Exception as exc: # Errors raised by task modules at import time. + return None, _error_from_exception(exc) + try: + return json.loads(captured.getvalue()), None + except json.JSONDecodeError as exc: + return None, { + "code": "internal_error", + "message": f"Invalid describe output: {exc}", + } + + +def _output_ref(ref: Any) -> dict[str, Any]: + return {"task": ref.task_name, "field": ref.output_field} + + +def dependency_spec(deps: Any) -> Any: + """Render dependencies the same way ``taskmaestro workflow describe`` does.""" + from taskmaestro.dependencies import CollectionRef + + if deps is None: + return None + if isinstance(deps, str): + return {"task": deps, "field": None} + if isinstance(deps, tuple): + return {"task": deps[0], "field": deps[1]} + result: dict[str, Any] = {} + for field, ref in deps.items(): + if isinstance(ref, CollectionRef): + members: list[Any] | dict[str, Any] + if ref.kind == "keyed": + members = {key: _output_ref(item) for key, item in ref.keyed_members} + else: + members = [_output_ref(item) for item in ref.positional_members] + result[field] = {"collect": {"kind": ref.kind, "members": members}} + elif isinstance(ref, tuple): + result[field] = {"task": ref[0], "field": ref[1]} + else: + result[field] = {"task": ref, "field": None} + return result + + +def describe_workflow(workflow: Any) -> dict[str, Any]: + """Describe an in-memory workflow without input values.""" + from taskmaestro.task import get_input_type + + tasks: list[dict[str, Any]] = [] + for name, task in workflow.topological_order(): + input_type = get_input_type(task) + output_type = workflow.get_output_annotation(name) + task_map = workflow.get_task_map(name) + tasks.append( + { + "name": name, + "python_type": python_type_name(task), + "depends_on": dependency_spec(workflow.get_dependencies(name)), + "config_fields": sorted(workflow.get_config_fields(name)), + "provided_config_fields": None, + "missing_config_fields": None, + "config_values": None, + "required_input_fields": sorted( + field + for field, info in input_type.model_fields.items() + if info.is_required() + ), + "map": asdict(task_map) if task_map is not None else None, + "input_schema": model_json_schema(input_type), + "output_schema": model_json_schema(output_type), + } + ) + return { + "workflow": workflow.name, + "result_task": workflow.result_task_name, + "tasks": tasks, + } diff --git a/GrpcInterface/Python/rips/taskmaestro_helper/run.py b/GrpcInterface/Python/rips/taskmaestro_helper/run.py index 8ed05d409e..afc027ebd3 100644 --- a/GrpcInterface/Python/rips/taskmaestro_helper/run.py +++ b/GrpcInterface/Python/rips/taskmaestro_helper/run.py @@ -91,12 +91,31 @@ def make_rips_resolver(rips_instance: Any) -> Callable[[str, dict[str, Any]], An return resolve +def _load_workflow(workflow_dir: Path | None, registered_id: str | None) -> Any: + if registered_id is not None: + from taskmaestro.discovery import get_registered_workflow + + return get_registered_workflow(registered_id) + + from taskmaestro.yaml_config import _load_workflow_only + + assert workflow_dir is not None + workflow, _ = _load_workflow_only(workflow_dir / "workflow.yaml") + return workflow + + def run_workflow( - workflow_dir: Path, input_path: Path, grpc_port: int, run_id: str = "" + workflow_dir: Path | None, + input_path: Path, + grpc_port: int, + run_id: str = "", + registered_id: str | None = None, ) -> int: - workflow_dir_str = str(workflow_dir) - if workflow_dir_str not in sys.path: - sys.path.insert(0, workflow_dir_str) + """Run a YAML workflow folder, or a registered workflow by its id.""" + if workflow_dir is not None: + workflow_dir_str = str(workflow_dir) + if workflow_dir_str not in sys.path: + sys.path.insert(0, workflow_dir_str) import rips @@ -119,10 +138,12 @@ def run_workflow( return 1 from taskmaestro import EmptyConfig, ExecutionContext, Job, JobConfiguration, Runner - from taskmaestro.yaml_config import _load_workflow_only - workflow_yaml = workflow_dir / "workflow.yaml" - workflow, _ = _load_workflow_only(workflow_yaml) + try: + workflow = _load_workflow(workflow_dir, registered_id) + except Exception as exc: + _emit("error", message=f"Failed to load workflow: {exc}") + return 1 job_config = JobConfiguration(resolved) job: Job[Any] = Job( @@ -146,11 +167,20 @@ def main(argv: list[str]) -> int: parser = argparse.ArgumentParser( prog="run", description="Run a taskmaestro workflow." ) - parser.add_argument("workflow_dir", type=Path) + parser.add_argument("workflow_dir", type=Path, nargs="?") + parser.add_argument( + "--registered", metavar="ID", help="Run a registered workflow by its id" + ) 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) + if (args.workflow_dir is None) == (args.registered is None): + parser.error("specify either a workflow folder or --registered ") return run_workflow( - args.workflow_dir.resolve(), args.input.resolve(), args.grpc_port, args.run_id + args.workflow_dir.resolve() if args.workflow_dir is not None else None, + args.input.resolve(), + args.grpc_port, + args.run_id, + registered_id=args.registered, ) diff --git a/GrpcInterface/Python/rips/taskmaestro_helper/schema.py b/GrpcInterface/Python/rips/taskmaestro_helper/schema.py new file mode 100644 index 0000000000..8990e02f50 --- /dev/null +++ b/GrpcInterface/Python/rips/taskmaestro_helper/schema.py @@ -0,0 +1,85 @@ +"""JSON schemas for task input/output types, as consumed by ResInsight. + +The generator mirrors taskmaestro's own CLI schema generator (opaque Python +objects become ``{"not": {}, "x-taskmaestro-opaque": true, ...}``) and adds +``x-ri-python-type``/``x-ri-python-bases`` to models and opaque types. The bases +list the class MRO so ResInsight can check port compatibility the same way +taskmaestro does (``issubclass``) without importing anything. +""" + +from __future__ import annotations + +import inspect +from typing import Any + +from pydantic import BaseModel +from pydantic.json_schema import GenerateJsonSchema, JsonSchemaValue +from pydantic_core import core_schema + +_IGNORED_BASES = {object, BaseModel} + + +def python_type_name(cls: type[Any]) -> str: + return f"{cls.__module__}.{cls.__qualname__}" + + +def python_bases(cls: type[Any]) -> list[str]: + """Return the dotted names of ``cls`` and its bases, most derived first.""" + names: list[str] = [] + for base in inspect.getmro(cls): + if base in _IGNORED_BASES or base.__module__ == "typing": + continue + name = python_type_name(base) + if name not in names: + names.append(name) + return names + + +def _type_annotations(cls: type[Any]) -> dict[str, Any]: + return { + "x-ri-python-type": python_type_name(cls), + "x-ri-python-bases": python_bases(cls), + } + + +class ResInsightSchemaGenerator(GenerateJsonSchema): + """Schema generator that marks opaque types and records Python class bases.""" + + def is_instance_schema( + self, schema: core_schema.IsInstanceSchema + ) -> JsonSchemaValue: + cls = schema["cls"] + return { + "not": {}, + "x-taskmaestro-opaque": True, + "x-taskmaestro-python-type": python_type_name(cls), + **_type_annotations(cls), + } + + def model_schema(self, schema: core_schema.ModelSchema) -> JsonSchemaValue: + json_schema = super().model_schema(schema) + json_schema.update(_type_annotations(schema["cls"])) + return json_schema + + +def model_json_schema(model: type[Any]) -> dict[str, Any]: + if isinstance(model, type) and issubclass(model, BaseModel): + return model.model_json_schema(schema_generator=ResInsightSchemaGenerator) + from pydantic import TypeAdapter + + return TypeAdapter(model).json_schema(schema_generator=ResInsightSchemaGenerator) + + +def task_descriptor(task_id: str, cls: type[Any]) -> dict[str, Any]: + """Describe a task class for the ResInsight task palette and port typing.""" + from taskmaestro.task import get_input_type, get_output_type + + return { + "id": task_id, + "name": getattr(cls, "name", cls.__name__), + "python_type": python_type_name(cls), + "description": inspect.getdoc(cls) or "", + "timeout_seconds": getattr(cls, "timeout_seconds", None), + "input_schema": model_json_schema(get_input_type(cls)), + "output_schema": model_json_schema(get_output_type(cls)), + } diff --git a/GrpcInterface/Python/rips/tests/test_data/taskmaestro_workflows/resinsight_completions/input.yaml b/GrpcInterface/Python/rips/tests/test_data/taskmaestro_workflows/resinsight_completions/input.yaml new file mode 100644 index 0000000000..51be760c0e --- /dev/null +++ b/GrpcInterface/Python/rips/tests/test_data/taskmaestro_workflows/resinsight_completions/input.yaml @@ -0,0 +1,22 @@ +# Per-task input data for the ResInsight Completions Pipeline +# +# Each top-level key is a task name, with its configuration values. +# `case` (select_eclipse_case) and `well_path` (select_well_path_*) are +# runtime-picker fields supplied by the caller — not literal values here. + +connect_to_resinsight: {} +select_eclipse_case: {} +select_well_path_1: {} +select_well_path_2: {} +add_perf_1: + event_date: "2024-01-01" + start_md: 3000.0 + end_md: 3500.0 +add_perf_2: + event_date: "2024-02-01" + start_md: 2400.0 + end_md: 2600.0 + +export_completions: + event_date: "2024-05-01" + export_path: "/tmp/completions.txt" diff --git a/GrpcInterface/Python/rips/tests/test_data/taskmaestro_workflows/resinsight_completions/workflow.yaml b/GrpcInterface/Python/rips/tests/test_data/taskmaestro_workflows/resinsight_completions/workflow.yaml new file mode 100644 index 0000000000..5ea52dedd4 --- /dev/null +++ b/GrpcInterface/Python/rips/tests/test_data/taskmaestro_workflows/resinsight_completions/workflow.yaml @@ -0,0 +1,52 @@ +# ResInsight Completions Pipeline — YAML workflow configuration +# +# Tasks use stable entry-point identifiers published by taskmaestro-resinsight, +# rather than Python module/class paths. +# +# DAG with fan-out / fan-in pattern: +# ConnectToResInsight → SelectEclipseCase → [SelectWellPath x2] → [AddPerforation x2] → ExportCompletions + +workflow: + name: resinsight_completions + tasks: + - task: resinsight.connect + - task: resinsight.select_eclipse_case + depends_on: + resinsight: resinsight.connect + config_fields: [case] + - task: resinsight.select_well_path + name: select_well_path_1 + depends_on: + resinsight: resinsight.connect + grid_case: resinsight.select_eclipse_case + config_fields: [well_path] + - task: resinsight.select_well_path + name: select_well_path_2 + depends_on: + resinsight: resinsight.connect + grid_case: resinsight.select_eclipse_case + config_fields: [well_path] + - task: resinsight.add_perforation + name: add_perf_1 + depends_on: + resinsight: resinsight.connect + well_path: select_well_path_1 + config_fields: [event_date, start_md, end_md] + - task: resinsight.add_perforation + name: add_perf_2 + depends_on: + resinsight: resinsight.connect + well_path: select_well_path_2 + config_fields: [event_date, start_md, end_md] + - task: resinsight.export_completions + depends_on: + resinsight: resinsight.connect + grid_case: resinsight.select_eclipse_case + perforation_1: add_perf_1 + perforation_2: add_perf_2 + config_fields: [event_date, export_path] + +runner: + hooks: + - hook: taskmaestro.hooks.logging.LoggingHook + - hook: taskmaestro.hooks.timing.TimingHook diff --git a/GrpcInterface/Python/rips/tests/test_data/taskmaestro_workflows/resinsight_load_data/input.yaml b/GrpcInterface/Python/rips/tests/test_data/taskmaestro_workflows/resinsight_load_data/input.yaml new file mode 100644 index 0000000000..87ad0532d2 --- /dev/null +++ b/GrpcInterface/Python/rips/tests/test_data/taskmaestro_workflows/resinsight_load_data/input.yaml @@ -0,0 +1,10 @@ +# Paths are supplied for the selected ROFF grid, its property file, and surface. + +connect_to_resinsight: {} +load_model: + path: /path/to/grid.roff +import_grid_property: + paths: + - /path/to/property.roffasc +load_regular_surface: + path: /path/to/surface.irap diff --git a/GrpcInterface/Python/rips/tests/test_data/taskmaestro_workflows/resinsight_load_data/workflow.yaml b/GrpcInterface/Python/rips/tests/test_data/taskmaestro_workflows/resinsight_load_data/workflow.yaml new file mode 100644 index 0000000000..c0a0270e9d --- /dev/null +++ b/GrpcInterface/Python/rips/tests/test_data/taskmaestro_workflows/resinsight_load_data/workflow.yaml @@ -0,0 +1,20 @@ +# ResInsight grid, property, and regular surface loading example. + +workflow: + name: resinsight_load_data + result_task: resinsight.import_grid_property + tasks: + - task: resinsight.connect + - task: resinsight.load_model + depends_on: + resinsight: resinsight.connect + config_fields: [path] + - task: resinsight.import_grid_property + depends_on: + resinsight: resinsight.connect + grid_case: resinsight.load_model + config_fields: [paths] + - task: resinsight.load_regular_surface + depends_on: + resinsight: resinsight.connect + config_fields: [path] diff --git a/GrpcInterface/Python/rips/tests/test_data/taskmaestro_workflows/tm_helper_pipeline.py b/GrpcInterface/Python/rips/tests/test_data/taskmaestro_workflows/tm_helper_pipeline.py new file mode 100644 index 0000000000..1193e5ea57 --- /dev/null +++ b/GrpcInterface/Python/rips/tests/test_data/taskmaestro_workflows/tm_helper_pipeline.py @@ -0,0 +1,141 @@ +"""Small taskmaestro tasks used by the taskmaestro helper tests.""" + +from __future__ import annotations + +from pydantic import BaseModel +from taskmaestro import EmptyConfig, ExecutionContext, Task, Workflow + + +class Number(BaseModel): + """A single number.""" + + value: float + + +class StartInput(BaseModel): + """Configured start value.""" + + value: float + + +class Wrapped(BaseModel): + """A number wrapped in another model.""" + + inner: Number + + +class Pair(BaseModel): + """Two numbers to add.""" + + first: float + second: float + + +class Values(BaseModel): + """Collected numbers.""" + + values: list[float] + + +class Item(BaseModel): + """One mapped item.""" + + key: str + value: float + + +class SpecialNumber(Number): + """A number subclass, compatible with Number.""" + + +class Start(Task[StartInput, Number]): + """Produce the configured value.""" + + name = "start" + + def run(self, input: StartInput, ctx: ExecutionContext) -> Number: + return Number(value=input.value) + + +class Constant(Task[EmptyConfig, Number]): + """Produce the number one.""" + + name = "constant" + + def run(self, input: EmptyConfig, ctx: ExecutionContext) -> Number: + return Number(value=1.0) + + +class Double(Task[Number, Number]): + """Double a number.""" + + name = "double" + + def run(self, input: Number, ctx: ExecutionContext) -> Number: + return Number(value=2 * input.value) + + +class Wrap(Task[Number, Wrapped]): + """Wrap a number.""" + + name = "wrap" + + def run(self, input: Number, ctx: ExecutionContext) -> Wrapped: + return Wrapped(inner=input) + + +class Add(Task[Pair, Number]): + """Add two numbers.""" + + name = "add" + + def run(self, input: Pair, ctx: ExecutionContext) -> Number: + return Number(value=input.first + input.second) + + +class Sum(Task[Values, Number]): + """Sum collected numbers.""" + + name = "sum" + + def run(self, input: Values, ctx: ExecutionContext) -> Number: + return Number(value=sum(input.values)) + + +class PerItem(Task[Item, Number]): + """Return the value of a mapped item.""" + + name = "per_item" + + def run(self, input: Item, ctx: ExecutionContext) -> Number: + return Number(value=input.value) + + +class Special(Task[Number, SpecialNumber]): + """Produce a SpecialNumber.""" + + name = "special" + + def run(self, input: Number, ctx: ExecutionContext) -> SpecialNumber: + return SpecialNumber(value=input.value) + + +registered_workflow = ( + Workflow.builder("registered_flow") + .add_task(Start, config_fields=["value"]) + .add_task(Double, depends_on=Start) + .build() +) + + +def _local_workflow() -> Workflow: + class LocalTask(Task[EmptyConfig, Number]): + name = "local" + + def run(self, input: EmptyConfig, ctx: ExecutionContext) -> Number: + return Number(value=0.0) + + return Workflow.builder("local_flow").add_task(LocalTask).build() + + +local_workflow = _local_workflow() diff --git a/GrpcInterface/Python/rips/tests/test_taskmaestro_helper_definition.py b/GrpcInterface/Python/rips/tests/test_taskmaestro_helper_definition.py new file mode 100644 index 0000000000..13c04ca2a1 --- /dev/null +++ b/GrpcInterface/Python/rips/tests/test_taskmaestro_helper_definition.py @@ -0,0 +1,716 @@ +"""Tests for the workflow definition subcommands of rips.taskmaestro_helper. + +Run with ``pytest --noconftest``: these tests do not need a running ResInsight. +""" + +from __future__ import annotations + +import json +import shutil +import textwrap +from importlib.metadata import EntryPoint +from pathlib import Path +from typing import Any + +import pytest + +pytest.importorskip("taskmaestro") + +from rips.taskmaestro_helper import __main__ as helper_main # noqa: E402 +from rips.taskmaestro_helper.catalog import build_catalog, export_registered # noqa: E402 +from rips.taskmaestro_helper.definition import ( # noqa: E402 + load_definition, + write_definition, +) +from rips.taskmaestro_helper.describe import describe_yaml # noqa: E402 + +TEST_DATA = Path(__file__).parent / "test_data" / "taskmaestro_workflows" +PIPELINE = "tm_helper_pipeline" + +STRUCTURAL_KEYS = ( + "name", + "result_task", + "nodes", + "edges", + "inputs", + "editable", + "header_comment", + "passthrough", + "task_types", +) + + +@pytest.fixture(autouse=True) +def pipeline_on_path(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.syspath_prepend(str(TEST_DATA)) + + +def _fake_entry_points( + monkeypatch: pytest.MonkeyPatch, + tasks: dict[str, str], + workflows: dict[str, str] | None = None, +) -> None: + from taskmaestro import discovery + + groups = { + discovery.TASK_ENTRY_POINT_GROUP: [ + EntryPoint(name, value, discovery.TASK_ENTRY_POINT_GROUP) + for name, value in tasks.items() + ], + discovery.WORKFLOW_ENTRY_POINT_GROUP: [ + EntryPoint(name, value, discovery.WORKFLOW_ENTRY_POINT_GROUP) + for name, value in (workflows or {}).items() + ], + } + + def entry_points(*, group: str) -> list[EntryPoint]: + return groups.get(group, []) + + monkeypatch.setattr(discovery, "entry_points", entry_points) + + +@pytest.fixture +def plugins(monkeypatch: pytest.MonkeyPatch) -> None: + _fake_entry_points( + monkeypatch, + tasks={ + "test.start": f"{PIPELINE}:Start", + "test.double": f"{PIPELINE}:Double", + "test.add": f"{PIPELINE}:Add", + "test.constant": f"{PIPELINE}:Constant", + "test.broken": "tm_helper_missing_module:Broken", + }, + workflows={ + "test.flow": f"{PIPELINE}:registered_workflow", + "test.local": f"{PIPELINE}:local_workflow", + }, + ) + + +def _write_workflow(directory: Path, workflow: str, inputs: str | None = None) -> Path: + directory.mkdir(parents=True, exist_ok=True) + (directory / "workflow.yaml").write_text(textwrap.dedent(workflow)) + if inputs is not None: + (directory / "input.yaml").write_text(textwrap.dedent(inputs)) + return directory + + +def _structure(definition: dict[str, Any]) -> dict[str, Any]: + return {key: definition[key] for key in STRUCTURAL_KEYS} + + +def _describe_summary(description: dict[str, Any]) -> dict[str, Any]: + return { + "result_task": description["result_task"], + "tasks": [ + (task["name"], task["depends_on"], task["config_fields"]) + for task in description["tasks"] + ], + } + + +def _round_trip(source: Path, out_dir: Path) -> tuple[dict[str, Any], dict[str, Any]]: + first = load_definition(source, describe=True) + assert first["editable"], first["readonly_reasons"] + assert first["describe_error"] is None, first["describe_error"] + write_definition(first, out_dir) + second = load_definition(out_dir, describe=True) + assert second["describe_error"] is None, second["describe_error"] + assert _structure(second) == _structure(first) + assert _describe_summary(second["describe"]) == _describe_summary(first["describe"]) + third_dir = out_dir.parent / (out_dir.name + "_again") + write_definition(second, third_dir) + assert (third_dir / "workflow.yaml").read_text() == ( + out_dir / "workflow.yaml" + ).read_text() + return first, second + + +# --- Catalog --- + + +def test_catalog_reports_broken_plugin_and_loads_the_rest(plugins: None) -> None: + catalog = build_catalog() + ids = [task["id"] for task in catalog["tasks"]] + assert ids == ["test.add", "test.constant", "test.double", "test.start"] + assert [error["id"] for error in catalog["errors"]] == ["test.broken"] + + start = next(task for task in catalog["tasks"] if task["id"] == "test.start") + assert start["name"] == "start" + assert start["description"] == "Produce the configured value." + assert start["python_type"] == f"{PIPELINE}.Start" + assert start["input_schema"]["properties"]["value"]["type"] == "number" + + workflows = {workflow["id"]: workflow for workflow in catalog["workflows"]} + assert set(workflows) == {"test.flow", "test.local"} + assert workflows["test.flow"]["editable"] + assert not workflows["test.local"]["editable"] + assert workflows["test.flow"]["describe"]["result_task"] == "double" + + +def test_catalog_without_workflows(plugins: None) -> None: + assert build_catalog(include_workflows=False)["workflows"] == [] + + +def test_catalog_cli_prints_one_json_document( + plugins: None, capsys: pytest.CaptureFixture[str] +) -> None: + assert helper_main.main(["catalog", "--no-workflows"]) == 0 + out = capsys.readouterr().out + document = json.loads(out) + assert document["status"] == "ok" + assert len(document["tasks"]) == 4 + + +def test_cli_failure_prints_invalid_document( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + assert helper_main.main(["load", str(tmp_path / "missing")]) == 2 + document = json.loads(capsys.readouterr().out) + assert document["status"] == "invalid" + assert "Cannot read" in document["error"]["message"] + + +# --- Schema --- + + +def test_schema_records_python_bases() -> None: + from rips.taskmaestro_helper.schema import task_descriptor + from tm_helper_pipeline import Special + + descriptor = task_descriptor("test.special", Special) + output = descriptor["output_schema"] + assert output["x-ri-python-type"] == f"{PIPELINE}.SpecialNumber" + assert output["x-ri-python-bases"] == [ + f"{PIPELINE}.SpecialNumber", + f"{PIPELINE}.Number", + ] + + +def test_schema_perforation_output_is_not_a_well_path() -> None: + pytest.importorskip("taskmaestro_resinsight") + from taskmaestro import get_registered_task + + from rips.taskmaestro_helper.schema import task_descriptor + + add_perforation = task_descriptor( + "resinsight.add_perforation", get_registered_task("resinsight.add_perforation") + ) + output_bases = add_perforation["output_schema"]["x-ri-python-bases"] + well_path = add_perforation["input_schema"]["$defs"]["WellPath"] + assert well_path["x-ri-python-type"] == "taskmaestro_resinsight.models.WellPath" + assert well_path["x-ri-python-type"] not in output_bases + opaque = well_path["properties"]["value"] + assert opaque["x-taskmaestro-opaque"] is True + assert "rips.generated.generated_classes.WellPath" in opaque["x-ri-python-bases"] + + +# --- Round trips --- + + +def test_round_trip_whole_output_dependency(tmp_path: Path) -> None: + source = _write_workflow( + tmp_path / "src", + f"""\ + workflow: + name: whole + tasks: + - task: {PIPELINE}.Start + config_fields: [value] + - task: {PIPELINE}.Double + depends_on: {PIPELINE}.Start + """, + "start:\n value: 2.5\n", + ) + first, _ = _round_trip(source, tmp_path / "out") + assert first["edges"] == [ + {"from": "start", "output": None, "to": "double", "input": None} + ] + assert first["inputs"] == {"start": {"value": 2.5}, "double": {}} + text = (tmp_path / "out" / "workflow.yaml").read_text() + assert "name: start" in text and "depends_on: start" in text + assert "result_task" not in text + + +def test_round_trip_output_field_routing(tmp_path: Path) -> None: + source = _write_workflow( + tmp_path / "src", + f"""\ + workflow: + name: routed + tasks: + - task: {PIPELINE}.Start + name: first + config_fields: [value] + - task: {PIPELINE}.Wrap + name: wrap + depends_on: first + - task: {PIPELINE}.Double + name: unwrap + depends_on: [wrap, inner] + """, + "first:\n value: 1\n", + ) + first, _ = _round_trip(source, tmp_path / "out") + assert {"from": "wrap", "output": "inner", "to": "unwrap", "input": None} in first[ + "edges" + ] + assert ( + "depends_on: [wrap, inner]" in (tmp_path / "out" / "workflow.yaml").read_text() + ) + + +def test_round_trip_fan_in_with_field_routing_and_two_instances(tmp_path: Path) -> None: + source = _write_workflow( + tmp_path / "src", + f"""\ + workflow: + name: fan_in + result_task: add + tasks: + - task: {PIPELINE}.Start + name: left + config_fields: [value] + - task: {PIPELINE}.Start + name: right + config_fields: [value] + - task: {PIPELINE}.Add + name: add + depends_on: + first: [left, value] + second: [right, value] + - task: {PIPELINE}.Double + name: side + depends_on: left + """, + "left:\n value: 1\nright:\n value: 2\n", + ) + first, second = _round_trip(source, tmp_path / "out") + assert first["result_task"] == "add" + assert [node["name"] for node in second["nodes"]] == [ + "left", + "right", + "add", + "side", + ] + assert list(second["task_types"]) == [ + f"{PIPELINE}.Start", + f"{PIPELINE}.Add", + f"{PIPELINE}.Double", + ] + text = (tmp_path / "out" / "workflow.yaml").read_text() + assert "result_task: add" in text + assert "first: [left, value]" in text + + +def test_load_resolves_class_paths_to_instance_names(tmp_path: Path) -> None: + source = _write_workflow( + tmp_path / "src", + f"""\ + workflow: + name: by_path + tasks: + - task: {PIPELINE}.Constant + - task: {PIPELINE}.Double + depends_on: {PIPELINE}.Constant + """, + ) + definition = load_definition(source) + assert [node["name"] for node in definition["nodes"]] == ["constant", "double"] + assert definition["edges"][0]["from"] == "constant" + + +def test_linear_mode_is_converted_to_explicit_edges(tmp_path: Path) -> None: + source = _write_workflow( + tmp_path / "src", + f"""\ + workflow: + name: linear + tasks: + - task: {PIPELINE}.Constant + - task: {PIPELINE}.Double + - task: {PIPELINE}.Double + name: again + """, + ) + definition = load_definition(source) + assert definition["edges"] == [ + {"from": "constant", "output": None, "to": "double", "input": None}, + {"from": "double", "output": None, "to": "again", "input": None}, + ] + assert any("chained" in warning for warning in definition["warnings"]) + write_definition(definition, tmp_path / "out") + reloaded = load_definition(tmp_path / "out", describe=True) + assert reloaded["edges"] == definition["edges"] + assert reloaded["warnings"] == [] + assert reloaded["describe_error"] is None + + +def test_empty_depends_on_guard_keeps_unconnected_tasks_independent( + tmp_path: Path, +) -> None: + definition = load_definition( + _write_workflow( + tmp_path / "src", + f"""\ + workflow: + name: independent + result_task: second + tasks: + - task: {PIPELINE}.Constant + name: first + - task: {PIPELINE}.Constant + name: second + """, + ) + ) + definition["edges"] = [] + write_definition(definition, tmp_path / "out") + text = (tmp_path / "out" / "workflow.yaml").read_text() + assert "depends_on: {}" in text + reloaded = load_definition(tmp_path / "out", describe=True) + assert reloaded["edges"] == [] + assert reloaded["describe_error"] is None + assert [task["depends_on"] for task in reloaded["describe"]["tasks"]] == [{}, None] + + +def test_save_rejects_mixed_whole_and_field_inputs(tmp_path: Path) -> None: + from rips.taskmaestro_helper._compat import HelperError + + definition = load_definition( + _write_workflow( + tmp_path / "src", + f"""\ + workflow: + name: mixed + tasks: + - task: {PIPELINE}.Constant + name: one + - task: {PIPELINE}.Add + name: add + depends_on: + first: [one, value] + second: [one, value] + """, + ) + ) + definition["edges"].append( + {"from": "one", "output": None, "to": "add", "input": None} + ) + with pytest.raises(HelperError, match="whole input"): + write_definition(definition, tmp_path / "out") + + +def test_save_rejects_read_only_and_empty_workflows(tmp_path: Path) -> None: + from rips.taskmaestro_helper._compat import HelperError + + definition = load_definition( + _write_workflow( + tmp_path / "src", + f"""\ + workflow: + name: single + tasks: + - task: {PIPELINE}.Constant + """, + ) + ) + with pytest.raises(HelperError, match="read-only"): + write_definition({**definition, "editable": False}, tmp_path / "out") + with pytest.raises(HelperError, match="no tasks"): + write_definition({**definition, "nodes": []}, tmp_path / "out") + + +# --- Read-only workflows --- + + +@pytest.mark.parametrize( + ("tasks", "reason"), + [ + ( + f"""\ + - task: {PIPELINE}.Constant + name: a + - task: {PIPELINE}.Constant + name: b + - task: {PIPELINE}.Sum + name: total + depends_on: + values: + collect: [[a, value], [b, value]] + """, + "collect", + ), + ( + f"""\ + - task: {PIPELINE}.PerItem + name: items + map: {{over: items, key_as: key, value_as: value}} + """, + "map", + ), + ( + """\ + - workflow: inner.yaml + name: nested + """, + "nested", + ), + ( + """\ + - task: tm_helper_missing_module.Missing + """, + "Cannot load task", + ), + ], +) +def test_unsupported_features_open_read_only( + tmp_path: Path, tasks: str, reason: str +) -> None: + source = tmp_path / "src" + source.mkdir() + (source / "inner.yaml").write_text( + f"workflow:\n name: inner\n tasks:\n - task: {PIPELINE}.Constant\n" + ) + (source / "workflow.yaml").write_text( + "workflow:\n name: read_only\n tasks:\n" + + textwrap.indent(textwrap.dedent(tasks), " ") + ) + definition = load_definition(source) + assert not definition["editable"] + assert any(reason in text for text in definition["readonly_reasons"]) + + +# --- Header, passthrough and references --- + + +def test_header_and_passthrough_preserved_and_refs_stripped(tmp_path: Path) -> None: + source = _write_workflow( + tmp_path / "src", + f"""\ + # My workflow + # + # Explains things. + + workflow: + name: with_header + tasks: + - task: {PIPELINE}.Start + config_fields: [value] + # A comment that is lost. + runner: + timeout_seconds: 30 + hooks: + - hook: taskmaestro.hooks.timing.TimingHook + context: + correlation_id: abc + """, + """\ + start: + value: {__resinsight_ref__: EclipseCase, case_id: 0} + """, + ) + definition = load_definition(source) + assert definition["header_comment"] == "# My workflow\n#\n# Explains things." + assert definition["passthrough"] == { + "runner": { + "timeout_seconds": 30, + "hooks": [{"hook": "taskmaestro.hooks.timing.TimingHook"}], + }, + "context": {"correlation_id": "abc"}, + } + write_definition(definition, tmp_path / "out", backup=True) + text = (tmp_path / "out" / "workflow.yaml").read_text() + assert text.startswith("# My workflow\n#\n# Explains things.\n\nworkflow:") + assert "A comment that is lost" not in text + assert "timeout_seconds: 30" in text + assert "__resinsight_ref__" not in (tmp_path / "out" / "input.yaml").read_text() + reloaded = load_definition(tmp_path / "out") + assert reloaded["passthrough"] == definition["passthrough"] + assert reloaded["inputs"] == {"start": {}} + + +def test_backup_is_written_once(tmp_path: Path) -> None: + source = _write_workflow( + tmp_path / "src", + f"""\ + workflow: + name: backup + tasks: + - task: {PIPELINE}.Constant + """, + ) + original = (source / "workflow.yaml").read_text() + definition = load_definition(source) + result = write_definition(definition, source, backup=True) + assert result["backups"] == [str(source / "workflow.yaml.bak")] + write_definition(definition, source, backup=True) + assert (source / "workflow.yaml.bak").read_text() == original + + +def test_wired_input_drops_configured_value(tmp_path: Path) -> None: + definition = load_definition( + _write_workflow( + tmp_path / "src", + f"""\ + workflow: + name: wired + tasks: + - task: {PIPELINE}.Constant + name: one + - task: {PIPELINE}.Add + name: add + depends_on: + first: [one, value] + config_fields: [first, second] + """, + "add:\n first: 1\n second: 2\nunknown_task:\n x: 1\n", + ) + ) + add = next(node for node in definition["nodes"] if node["name"] == "add") + assert add["config_fields"] == ["second"] + assert definition["inputs"]["add"] == {"second": 2} + assert len(definition["warnings"]) == 2 + + +def test_save_cli_reads_definition_from_stdin( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + import io + + definition = load_definition( + _write_workflow( + tmp_path / "src", + f"""\ + workflow: + name: via_cli + tasks: + - task: {PIPELINE}.Start + config_fields: [value] + """, + ) + ) + monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps(definition))) + out_dir = tmp_path / "out" + assert helper_main.main(["save", "--out-dir", str(out_dir), "--describe"]) == 0 + result = json.loads(capsys.readouterr().out) + assert result["status"] == "ok" + assert result["describe"]["tasks"][0]["missing_config_fields"] == ["value"] + assert (out_dir / "input.yaml").read_text() == "start: {}\n" + + +def test_describe_yaml_reports_real_error_message(tmp_path: Path) -> None: + source = _write_workflow( + tmp_path / "src", + f"""\ + workflow: + name: broken + tasks: + - task: {PIPELINE}.Add + name: add + depends_on: + first: {PIPELINE}.Constant + """, + ) + description, error = describe_yaml(source / "workflow.yaml") + assert description is None + assert error is not None and "not found" in error["message"] + + +# --- Registered workflows --- + + +def test_export_registered(plugins: None) -> None: + definition = export_registered("test.flow", describe=True) + assert definition["editable"] + assert definition["source"]["registered_id"] == "test.flow" + assert definition["nodes"] == [ + {"name": "start", "task": "test.start", "config_fields": ["value"]}, + {"name": "double", "task": "test.double", "config_fields": []}, + ] + assert definition["edges"] == [ + {"from": "start", "output": None, "to": "double", "input": None} + ] + assert definition["result_task"] is None + assert definition["describe"]["result_task"] == "double" + + +def test_export_registered_local_class_is_read_only(plugins: None) -> None: + definition = export_registered("test.local") + assert not definition["editable"] + assert "cannot be referenced" in definition["readonly_reasons"][0] + + +def test_exported_registered_workflow_saves_and_validates( + plugins: None, tmp_path: Path +) -> None: + definition = export_registered("test.flow") + definition["inputs"] = {"start": {"value": 3}} + write_definition(definition, tmp_path / "out") + description, error = describe_yaml( + tmp_path / "out" / "workflow.yaml", tmp_path / "out" / "input.yaml" + ) + assert error is None + assert description is not None and description["result_task"] == "double" + + +def test_run_registered_workflow( + plugins: None, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + import rips + + from rips.taskmaestro_helper.run import PROGRESS_PREFIX, main + + input_path = tmp_path / "input.yaml" + input_path.write_text("start:\n value: 2\n") + monkeypatch.setattr(rips.Instance, "find", lambda **kwargs: object()) + + assert ( + main( + [ + "--registered", + "test.flow", + "--input", + str(input_path), + "--grpc-port", + "1234", + "--run-id", + "rid", + ] + ) + == 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"), + ("double", "running"), + ("double", "completed"), + ] + + +def test_run_requires_folder_or_registered_id(tmp_path: Path) -> None: + from rips.taskmaestro_helper.run import main + + with pytest.raises(SystemExit): + main(["--input", str(tmp_path / "input.yaml"), "--grpc-port", "1"]) + + +# --- The taskmaestro-resinsight examples --- + + +@pytest.mark.parametrize("name", ["resinsight_completions", "resinsight_load_data"]) +def test_example_workflows_round_trip(tmp_path: Path, name: str) -> None: + pytest.importorskip("taskmaestro_resinsight") + source = tmp_path / name + shutil.copytree(TEST_DATA / name, source) + first, _ = _round_trip(source, tmp_path / "out") + assert first["warnings"] == [] + assert all(node["task"].startswith("resinsight.") for node in first["nodes"]) diff --git a/README-experimental-workflow-ui.md b/README-experimental-workflow-ui.md index ad6201fcfe..871b5701a6 100644 --- a/README-experimental-workflow-ui.md +++ b/README-experimental-workflow-ui.md @@ -34,18 +34,27 @@ git clone https://github.com/OPM/taskmaestro-resinsight.git \ python -m pip install -e ~/resinsight-workflows/taskmaestro-resinsight ``` -ResInsight reads workflows with `python -m taskmaestro workflow describe`, which -needs a `taskmaestro` version that reports `config_values` -([OPM/taskmaestro#13](https://github.com/OPM/taskmaestro/pull/13)). Until that -is released, install it from Git: +The Workflow UI requires **taskmaestro 0.3.x** (0.3.0 or newer, below 0.4) and +**taskmaestro-resinsight 0.3.0** or newer. Check the installed versions: ```bash -python -m pip install "git+https://github.com/OPM/taskmaestro.git@main" +python -m pip show taskmaestro taskmaestro-resinsight | grep -E "^(Name|Version)" +``` + +If `taskmaestro` is older, upgrade it from Git: + +```bash +python -m pip install --upgrade "git+https://github.com/OPM/taskmaestro.git@main" ``` ## 2. Register the workflows -ResInsight discovers workflows in `~/.taskmaestro/workflows`: +Workflows that installed packages register, such as +`resinsight.completions`, appear automatically in the **Installed** folder +under **Workflows**. They are read-only, but they can be run and duplicated. + +ResInsight also discovers workflow folders in `~/.taskmaestro/workflows`. To +use the example folders from `taskmaestro-resinsight` directly, link them in: ```bash mkdir -p ~/.taskmaestro/workflows @@ -55,6 +64,8 @@ for workflow in ~/resinsight-workflows/taskmaestro-resinsight/workflows/*/; do done ``` +Workflows you create in ResInsight are saved to this folder by default. + ## 3. Configure ResInsight Open **Edit → Preferences** and set: @@ -69,18 +80,72 @@ Open **Edit → Preferences** and set: 3. **System → Experimental Features → Workflows** to on. Restart ResInsight. The **Workflows** node should now appear in the project -tree. Select a workflow, provide its inputs, and click **Run**. +tree. Select a workflow job, provide its inputs, and click **Run**. + +## 4. Create and edit workflows + +Selecting a workflow opens the workflow editor in a tab next to the 3D views. +It shows a palette of all installed tasks, the workflow graph, and a status +line with validation results. + +- **New workflow:** right-click **Workflows** and choose **New Workflow**. +- **Copy a workflow:** choose **Duplicate as Editable** on any workflow, + including installed ones. +- **Add a task:** drag it from the palette onto the graph, double-click it in + the palette, or right-click the graph and use **Add Task**. Tasks that need + a ResInsight connection are connected to a *connect* task automatically. +- **Connect tasks:** drag from an output port (right side of a task) to an + input port (left side). While dragging, valid targets are green; invalid + ones are grey and show the reason as a tooltip. +- **Config inputs:** every required input that is not connected becomes a + config input, shown in the property view when the task is selected. + Optional inputs can be turned on under **Optional Inputs** in the task's + context menu. +- **Rename, Set as Result Task, Delete:** right-click a task or connection. + F2 renames and Del deletes the selection. Ctrl+Z and Ctrl+Y undo and redo. + +The workflow is validated with taskmaestro shortly after each edit. Problems +are shown as badges on the tasks and in the status line. A workflow that +fails validation can still be run after confirming. + +Running a workflow with unsaved edits runs the edited version from a +temporary folder. The files on disk are not changed until you save. + +### Saving + +**Save** (Ctrl+S) writes `workflow.yaml` and `input.yaml` to the workflow +folder. **Save As** saves to a new folder. Note that: + +- The first save keeps the original as `workflow.yaml.bak`. +- The YAML is rewritten. The comment block at the top of `workflow.yaml` is + kept; other comments are lost. +- `input.yaml` gets the values of the workflow's first job (**Default**). + Selected cases, well paths, and views are not saved. +- Folders outside `~/.taskmaestro/workflows` are not found when ResInsight + starts or rescans. + +Use **Rescan Workflows** on the **Workflows** node after changing workflow +folders or installing packages. ResInsight asks before closing or rescanning +when there are unsaved edits. + +Workflows that use `collect`, `map`, or nested workflows open read-only, and +the status line explains why. They can still be run. ## Troubleshooting - **No Workflows node:** Enable the experimental feature and restart ResInsight. - **The Workflows node is empty:** Check that each directory under - `~/.taskmaestro/workflows` contains `workflow.yaml`, then restart ResInsight. + `~/.taskmaestro/workflows` contains `workflow.yaml`, then use **Rescan + Workflows**. +- **The task palette is empty or the Installed folder is missing:** The task + catalog failed to load. Hover over *Task catalog not available* in the + palette to see why. Check that + `taskmaestro` and the task packages are installed in the configured Python + environment. - **A Python module cannot be found:** Confirm that **Python Executable Location** points to the virtual environment created above. -- **A workflow fails to load with `invalid choice: 'workflow'`, or its inputs - are not pre-filled from `input.yaml`:** The installed `taskmaestro` is too - old; reinstall it as described above. +- **Workflows fail to load with a message about the taskmaestro version:** + Upgrade `taskmaestro` as described above. - **The workflow cannot connect:** Enable **Python Script Server** and restart ResInsight. diff --git a/docs/workflow-ui-design.md b/docs/workflow-ui-design.md new file mode 100644 index 0000000000..1ebdbd6289 --- /dev/null +++ b/docs/workflow-ui-design.md @@ -0,0 +1,418 @@ +# Workflow UI design + +## What this branch adds + +A new **Workflows** node in the project tree, next to **Scripts**. It lists +[taskmaestro](https://github.com/OPM/taskmaestro) workflows found on disk and +the workflows that installed packages register. For each one it +auto-generates a property editor for the config inputs, and it runs the +workflow as a subprocess that talks back to ResInsight over the existing gRPC +channel. + +Workflows can also be **created and edited** in ResInsight. A graph editor +with a task palette builds the workflow from registered tasks and saves it as +a standard `workflow.yaml` + `input.yaml`. + +The task classes are the source of truth. ResInsight reads the Pydantic input +and output models of each task, which are exported as JSON schemas, to build +the property editors and to check which ports can be connected. There is no +manifest file or plugin descriptor. + +## Architecture at a glance + +``` +ResInsight (C++) Python helper (rips.taskmaestro_helper) +───────────────── ─────────────────────────────────────── +RimWorkflowTaskCatalog ────── catalog ─────► entry points → task descriptors + registered workflows +RimWorkflowCollection + folders in ~/.taskmaestro/workflows ─ load ─► workflow.yaml + input.yaml → definition JSON + Installed (RimWorkflowInstalledCollection) (from the catalog) +RimWorkflow + RimWorkflowDefinition (value struct) + edits → applyEdit → undo/redo, dirty + Save ──────────────── save (stdin JSON) ──► definition JSON → workflow.yaml + input.yaml + RiuWorkflowValidationRunner ─ save --describe ► temp folder + taskmaestro describe + RimWorkflowJob (one or more) + RimWorkflowTaskInput (one per task) + RimWorkflowBinding (one per config field, see table below) + Run ─ writes input.yaml ─ QProcess ─────► run | run --registered + ◄───── JSON events → task states in the graph +``` + +The C++ side never parses YAML and doesn't import Pydantic. It only handles +JSON (Qt's `QJsonDocument`); `input.yaml` for a run is written as JSON text, +which YAML accepts. The Python helper does everything that touches YAML or +the upstream library. Every helper call goes through +`RimWorkflowHelperProcess::runSync` (or the async validation runner), which +finds the Python interpreter and decodes the single JSON document on stdout. + +## The Python helper + +`python -m rips.taskmaestro_helper ` +([__main__.py](../GrpcInterface/Python/rips/taskmaestro_helper/__main__.py)). +Every subcommand except `run` prints exactly one JSON document. Output +written while task modules are imported goes to stderr. On failure the +helper exits with code 2 and prints +`{"status": "invalid", "error": {"message", "task", "field"}}`. +taskmaestro 0.3.0 or newer is required (`_compat.py`). + +| Subcommand | Purpose | +|---|---| +| `catalog [--no-workflows]` | All tasks from the `taskmaestro.tasks` entry points, as descriptors (`id`, `name`, `python_type`, `description`, `input_schema`, `output_schema`), and all workflows from `taskmaestro.workflows`. A broken plugin is reported in `errors`; the rest still load. | +| `load [--describe]` | Reads `workflow.yaml` + `input.yaml` into a definition JSON (below). | +| `save --out-dir D [--workflow-only] [--describe] [--backup]` | Reads a definition JSON on stdin and writes the YAML files atomically. `--describe` also runs `taskmaestro workflow describe` on the result, which is how validation works. | +| `export-registered [--describe]` | A registered workflow as a definition JSON. | +| `run ` / `run --registered ` | Runs a workflow (see [The run flow](#the-run-flow)). | + +`schema.py` generates the task schemas. It adds `x-ri-python-type` and +`x-ri-python-bases` (the class MRO) to models and opaque types, so that C++ +can check subclass relations without Python. + +## The definition model + +`RimWorkflowDefinition` +([RimWorkflowDefinition.h](../ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDefinition.h)) +is a plain value struct that mirrors the helper's definition JSON: + +```json +{ "format": 1, "name": "resinsight_completions", "result_task": null, + "editable": true, "readonly_reasons": [], "warnings": [], + "header_comment": "# ...", "passthrough": {"runner": {}}, + "nodes": [{"name": "add_perf_1", "task": "resinsight.add_perforation", + "config_fields": ["event_date", "start_md", "end_md"]}], + "edges": [{"from": "connect_to_resinsight", "output": null, + "to": "add_perf_1", "input": "resinsight"}], + "inputs": {"add_perf_1": {"start_md": 3000.0}}, + "task_types": {"resinsight.add_perforation": {"...": "descriptor"}}, + "describe": {}, "describe_error": null } +``` + +- A `null` `output`/`input` on an edge means the whole model. `[task, field]` + routing and fan-in (`{field: task}`) are both expressed as edges. +- `task_types` embeds the descriptor of every task used, so a workflow can + still be edited when the catalog fails to load. +- `save` always writes `name:` on every entry and refers to tasks by instance + name, because taskmaestro cannot resolve default names in `depends_on`. + Linear (implicit) workflows are converted to explicit edges on load. +- The definition is never written to the `.rsp` file. Undo and redo store + value copies (`RimWorkflow`, capped at 100 steps). + +All edits are pure functions in `RimWorkflowDefinitionTools` that return +`std::expected`: `addTask`, `removeTask`, +`connect`, `disconnect`, `renameTask`, `renameWorkflow`, `setResultTask` +and `setOptionalInputConfigured`. After each edit, `normalize` re-derives the +config fields: every required input field that no edge covers becomes a +config field, as do optional fields the user has turned on. + +`addTask` gives the node a unique instance name. It also auto-wires inputs of +type `RipsInstance` to a `resinsight.connect` node, which is created the first +time it is needed. + +### Read-only workflows + +A workflow opens read-only, showing the reasons in the editor's status line, +when: + +- it is **installed** (registered by a package). Use *Duplicate as Editable* + to get an editable copy. +- it uses features the editor does not support: `collect`, `map`, nested + `workflow:` entries, or task classes that cannot be imported. + +Read-only workflows can still be run. + +## Port typing + +`RimWorkflowPortCompatibility` lists the input and output ports of a task from +its schemas. The whole model is a port with an empty name, and the opaque +`value` field of an `ObjectModel` is hidden. `isCompatible(produced, expected)` +checks the following, in order: + +1. `Any` accepts everything. +2. `anyOf`: every produced option must match; at least one expected option + must match. +3. Opaque types: the expected Python type must be among the produced type's + bases, so `PerforationOutput` does not connect to `WellPath` even though + both wrap `rips.WellPath`. +4. Models: compare `x-ri-python-type` against the produced bases. +5. Arrays: compare the items. +6. Scalars: `type` and `format` must be equal. bool → int is allowed; + int → float is not. Enums must be a subset. + +`canConnect` also rejects self-loops, cycles, and a whole-input edge on a task +that already has field edges (and the reverse). The error text is shown as a +tooltip while dragging. + +## Workflow graph and editor + +Selecting a `RimWorkflow` (or one of its jobs) shows `RiuWorkflowEditorWidget` +in a central dock tab: + +- a toolbar with Save, Save As, Undo, Redo, Duplicate as Editable, and Fit; +- the task palette (`RiuWorkflowTaskPalette`): a filter field above a tree + grouped by id prefix. Drag a task onto the graph, or double-click it; +- the graph (`RiuWorkflowGraphView`); +- a status line for load errors, read-only reasons, validation results and + unsaved changes. + +`RiuWorkflowGraphLayout` computes the layout: topological ranks with a Kahn +sort and longest-path ranks, then one barycenter pass to order nodes. It +tolerates cycles. The layout is always automatic; nodes moved by hand keep +their position only for the session. After an edit the viewport is kept. + +In an editable workflow: + +- Drag from an output port to an input port to connect. Valid targets turn + green, invalid ones grey with the reason as a tooltip. Escape cancels. +- Unwired optional inputs are drawn as hollow ports. +- Right-click a task for Rename (F2), Set as Result Task, Optional Inputs ▸ + and Delete (Del). Right-click a connection for Delete Connection. + Right-click the background for Add Task ▸, Fit and Save. +- Ctrl+Z/Ctrl+Y undo and redo; Ctrl+S saves. + +Selecting a task shows its `RimWorkflowTaskInput` from the displayed job in +the property view. Editing is locked while a job of the workflow is running. +During a run, task states are drawn on the nodes. + +## Validation + +Two layers of validation run: + +- **Static**, in C++ (`RimWorkflowDefinitionTools::validate`): unknown tasks, + invalid names, required opaque inputs that are not wired, multiple sinks + without a result task, and so on. The results are shown as badges on the + nodes right away. +- **Live**, through taskmaestro: `RiuWorkflowValidationRunner` waits 400 ms + after the last edit, then runs `save --out-dir --describe` in the + background. A new edit kills a stale run, results for an old revision are + dropped, and a 30 s watchdog stops hung processes. + `RimWorkflowValidationTools::issuesFromHelperResult` maps the errors to + tasks; anything left over goes to the status line. + +Running a workflow whose last validation failed asks for confirmation first. + +## Saving + +- **Save** writes `workflow.yaml` and `input.yaml` in the workflow folder. + The first save makes `workflow.yaml.bak`. Before overwriting, ResInsight + warns if the file changed on disk since it was loaded, or if the folder is + a symlink into a git checkout. +- **Save As** and **New Workflow** ask for a name and a folder. The default + folder is `~/.taskmaestro/workflows/`; a folder elsewhere is not + discovered on the next rescan, and ResInsight warns about that. `*.py` files + next to the original `workflow.yaml` are copied along, so tasks given as a + dotted path (`pipeline.MyTask`) still import. +- `input.yaml` gets the literal values of the **first job** (the "Default" + job). Object references (cases, well paths, views) are not saved. +- YAML comments other than the leading header block are lost, and the YAML + is reformatted. + +Closing ResInsight or the project, and **Rescan Workflows**, ask before +discarding unsaved edits. + +## Type / format → binding mapping + +`createBinding()` dispatches in this order — first `resinsight_type` (rips +object pickers take priority), then `format`, then bare `type`. Everything +unrecognised falls through to the string binding. + +| Field annotation (Pydantic, in the workflow's task) | Helper emits | C++ binding | PdmField type | Renders as | +|------------------------------------------------------------------|---------------------------------------|------------------------------|-------------------------------------|------------------------------------------| +| `rips.EclipseCase` / `ObjectModel[rips.EclipseCase]` | `resinsight_type: EclipseCase` | `RimWorkflowCaseBinding` | `PdmPtrField` | Combo box of loaded cases | +| `rips.WellPath` / `ObjectModel[rips.WellPath]` | `resinsight_type: WellPath` | `RimWorkflowWellPathBinding` | `PdmPtrField` | Combo box of well paths | +| `rips.View` / `rips.EclipseView` / wrapper | `resinsight_type: View` | `RimWorkflowViewBinding` | `PdmPtrField` | Combo box of `Case / View` labels | +| `datetime.date` / `datetime.datetime` | `type: string`, `format: date`* | `RimWorkflowDateBinding` | `PdmField` | Calendar widget (`PdmUiDateEditor`) | +| `pathlib.Path` / `pydantic.FilePath` / `pydantic.NewPath` | `type: string`, `format: path` / `file-path` | `RimWorkflowFilePathBinding` | `PdmField` (file mode) | Text field + `[Browse…]` (file dialog) | +| `pydantic.DirectoryPath` | `type: string`, `format: directory-path` | `RimWorkflowFilePathBinding` | `PdmField` (dir mode) | Text field + `[Browse…]` (folder dialog) | +| `bool` | `type: boolean` | `RimWorkflowBoolBinding` | `PdmField` | Checkbox | +| `int` | `type: integer` | `RimWorkflowIntBinding` | `PdmField` | Spinbox | +| `float` | `type: number` | `RimWorkflowFloatBinding` | `PdmField` | Decimal text field | +| `str` (or anything unrecognised) | `type: string` | `RimWorkflowStringBinding` | `PdmField` | Plain text field | + +\*`format` extraction goes through `pydantic.TypeAdapter(field).json_schema()` +so any future Pydantic-native format Pydantic supports is picked up +automatically — no hand-maintained map. The helper reassembles `Annotated` +forms before asking, otherwise `Annotated[Path, PathType('dir')]` would lose +its `dir` marker. + +`RimWorkflowFilePathBinding` is one class with an `m_selectDirectory` bool +toggled at `defineEditorAttribute` time; the same `caf::PdmField` +flips between file-open and folder-pick dialogs based on the schema's format. + +## Pre-filling from `input.yaml` + +A binding's initial value comes from, in order: + +1. the task's block in `input.yaml` (the definition's `inputs`), or +2. the field's `default=` in the Pydantic model, or +3. the `PdmField` default (empty string, 0.0, today's date, and so on). + +Bindings are kept with their values when the workflow is edited, as long as +the field still has the same kind of binding (`syncTaskInputs`). Values of +fields that become wired are kept aside and restored if the field is +unwired again. Renaming a task carries its values along. + +## The rips object reference round-trip + +For fields typed as rips classes, the config field schema carries +`resinsight_type` but no `default` (the C++ picker provides the value +interactively). When the user picks a case in the UI and runs the job, the +C++ binding's `toJsonValue()` writes: + +```yaml +{"load_model": {"case": {"__resinsight_ref__": "EclipseCase", "case_id": 0}}} +``` + +The reverse side, `rips.taskmaestro_helper.run.resolve_refs` +([run.py](../GrpcInterface/Python/rips/taskmaestro_helper/run.py)), walks +the YAML dict and replaces every `{__resinsight_ref__: , …id…}` map +with `{"value": }` before handing it to +`taskmaestro.JobConfiguration`. The `"value": …` shape is exactly what +Pydantic's `ObjectModel[T]` validator accepts, so workflow authors who +declare `case: GridCase` get a real `rips.EclipseCase` injected without +any custom validator code. + +ID schemes: + +| `resinsight_type` | Key in the YAML map | Resolves via | +|-------------------|----------------------|----------------------------------------------| +| `EclipseCase` | `case_id` | `rips.Project.case(case_id=…)` | +| `WellPath` | `well_path_name` | `rips.Project.well_path_by_name(…)` | +| `View` | `view_id` | scan `rips.Project.views()` by `.id` | + +Whitelist lives in +[refs.py](../GrpcInterface/Python/rips/taskmaestro_helper/refs.py) — the +single source of truth for the rips types the helper recognises and +resolves. Adding a new rips-typed +picker means: extend the whitelist, add a `RimWorkflowBinding` class, +register it in `createBinding()`. + +## The run flow + +1. The user runs a `RimWorkflowJob` (*Run* in the context menu or the + property page). +2. `RimWorkflowJob::runJob()` checks that the gRPC port is live, finds a + Python interpreter (`RiaPreferences::pythonExecutable()` → `python3` → + `python`), and writes the job's values to `input.yaml` in a temp folder. +3. `RimWorkflow::prepareRunSource()` picks what to run: + + | Workflow | Command | + |---|---| + | Installed | `run --registered ` | + | Saved, no unsaved edits | `run ` | + | Unsaved edits | `run ` with `PYTHONPATH=` | + + The edit folder is a private temp folder holding the current revision + (`save --workflow-only`). The files on disk are not touched until Save. +4. `RiuWorkflowJobRunner` starts the helper with + `--input /input.yaml --grpc-port --run-id `. The helper + connects to ResInsight, resolves refs, and runs the workflow through + `taskmaestro.Runner`. +5. The helper emits newline-delimited JSON events. Task state events update + the job and the colours of the graph nodes; everything else goes to the + log. Cancel terminates the process. + +## File layout (new) + +``` +ApplicationLibCode/ProjectDataModel/Workflow/ +├── RimWorkflow.{h,cpp} # one workflow: definition, undo, save, run source +├── RimWorkflowCollection.{h,cpp} # the project-tree collection, rescan +├── RimWorkflowInstalledCollection.{h,cpp} # the "Installed" folder +├── RimWorkflowJob.{h,cpp} # one set of input values; runs the workflow +├── RimWorkflowTaskInput.{h,cpp} # one entry per task +├── RimWorkflowDefinition.{h,cpp} # value structs: nodes, edges, issues +├── RimWorkflowDefinitionTools.{h,cpp} # JSON conversion, edit operations, static validation +├── RimWorkflowPortCompatibility.{h,cpp} # ports and type checks +├── RimWorkflowSchemaTools.{h,cpp} # JSON schema helpers +├── RimWorkflowTaskCatalog.{h,cpp} # cached `catalog` output +├── RimWorkflowHelperProcess.{h,cpp} # runs the Python helper +├── RimWorkflowValidationTools.{h,cpp} # helper result → issues +├── RimWorkflowDescribeTools.{h,cpp} # graph from `taskmaestro describe` (read-only workflows) +├── RimWorkflowFieldBinding.{h,cpp} # abstract binding base +└── RimWorkflowBinding.{h,cpp} # Bool, Int, Float, String, Date, FilePath, + # Array, Case, WellPath, View + +ApplicationLibCode/Commands/WorkflowCommands/ +├── RicNewWorkflowFeature, RicDuplicateWorkflowAsEditableFeature, +│ RicSaveWorkflowFeature, RicSaveWorkflowAsFeature, RicRescanWorkflowsFeature +├── RicAddWorkflowTaskFeature, RicDeleteWorkflowTaskFeature, +│ RicDeleteWorkflowConnectionFeature, RicRenameWorkflowTaskFeature, +│ RicSetWorkflowResultTaskFeature, RicToggleWorkflowOptionalInputFeature +├── RicNewWorkflowJobFeature, RicRunWorkflowJobFeature, RicCancelWorkflowJobFeature +├── RicWorkflowFeatureTools.{h,cpp} # shared helpers for the features +└── RicWorkflowLocationUi.{h,cpp} # name + folder dialog + +ApplicationLibCode/UserInterface/ +├── RiuWorkflowEditorWidget.{h,cpp} # the dock: toolbar, palette, graph, status +├── RiuWorkflowGraphView.{h,cpp} # QGraphicsView with editing +├── RiuWorkflowGraphLayout.{h,cpp} # pure layout (QtCore only) +├── RiuWorkflowTaskPalette.{h,cpp} +├── RiuWorkflowValidationRunner.{h,cpp} # debounced async validation +└── RiuWorkflowJobRunner.{h,cpp} # runs a job, parses events + +GrpcInterface/Python/rips/taskmaestro_helper/ +├── __main__.py # CLI dispatch +├── _compat.py # taskmaestro version check, HelperError +├── catalog.py # catalog, export-registered +├── definition.py # load, save +├── describe.py # taskmaestro describe, in process +├── schema.py # task schemas and descriptors +├── refs.py # rips type whitelist + REF_MARKER +└── run.py # resolve refs + invoke Runner + +GrpcInterface/Python/rips/tests/ +├── test_taskmaestro_helper.py +├── test_taskmaestro_helper_definition.py # run with --noconftest +└── test_data/taskmaestro_workflows/ + +ApplicationLibCode/UnitTests/ +├── RimWorkflowDefinition-Test.cpp # fixtures in TestData/RimWorkflowDefinition/ +├── RimWorkflowJobSync-Test.cpp +├── RimWorkflowDescribeTools-Test.cpp +└── RiuWorkflowGraphLayout-Test.cpp +``` + +Touched (existing files): + +- `ApplicationLibCode/ProjectDataModel/RimProject.{h,cpp}`: the + `workflowCollection` child field, attached at the `MainWindow.Scripts` tab + and marked with `disableIO()`. +- `ApplicationLibCode/UserInterface/RiuMainWindow.{h,cpp}`: hosts the editor + dock and connects graph selection to the property view. +- `ApplicationLibCode/Application/RiaGuiApplication.{h,cpp}`: asks before + unsaved workflow edits are discarded. + +## Constraints + +- **gRPC server must be on.** The run command refuses to launch otherwise. + Set in *Preferences → Python → Enable gRPC server*. +- **`PATH` must point at the Python that has `rips` and `taskmaestro` + installed**, because `findPythonExecutable()` searches `PATH`. Either + launch ResInsight from a shell with the venv on `PATH`, or set + *Preferences → Python → Python Executable Location* to an absolute path. +- **Rescan to pick up changes on disk.** Use *Rescan Workflows* on the + Workflows node after adding folders to `~/.taskmaestro/workflows/` or + installing packages. The task catalog is cached until then. +- **The collection is not serialised** to `.rsp`. Jobs and their values + live for one ResInsight session; save the workflow to keep the Default + job's values in `input.yaml`. + +## Where to extend + +- **New rips-typed picker** (e.g. `rips.SummaryCase`): add the class to + `RIPS_TYPE_WHITELIST` in `refs.py`, add the resolver branch to + `make_rips_resolver` in `run.py`, add a `RimWorkflowSummaryCaseBinding` + modelled on `RimWorkflowCaseBinding`, register it in `createBinding()`. +- **New scalar/format**: usually nothing on the Python side (TypeAdapter + handles it). On the C++ side, add a binding subclass and a one-line + dispatch case. +- **Change the discovery directory**: edit + `RimWorkflowCollection::discoveryDirectory()`. A `RiaPreferences` field + + Preferences UI would be a small follow-up if multiple roots are wanted. +- **New edit operation**: add a pure function to `RimWorkflowDefinitionTools` + with a unit test, call it through `RimWorkflow::applyEdit`, and expose it + with a `Ric*WorkflowFeature` that reads its arguments from `userData()`. +- **Supporting `collect`/`map`/nested workflows in the editor**: extend the + definition JSON and `RimWorkflowDefinitionNode`, drop the read-only reason + in `definition.py` and `structuralReadOnlyReasons`, and teach `save` to + write them.