diff --git a/ApplicationExeCode/Resources/ResInsight.qrc b/ApplicationExeCode/Resources/ResInsight.qrc
index 23126cbca0..f054e4cd39 100644
--- a/ApplicationExeCode/Resources/ResInsight.qrc
+++ b/ApplicationExeCode/Resources/ResInsight.qrc
@@ -350,4 +350,12 @@
components/images/well-completion.svg
components/images/well-completion-01.svg
+
+ codicons/calendar.svg
+ codicons/check.svg
+ codicons/file.svg
+ codicons/folder.svg
+ codicons/symbol-numeric.svg
+ codicons/symbol-string.svg
+
diff --git a/ApplicationExeCode/Resources/codicons/LICENSE b/ApplicationExeCode/Resources/codicons/LICENSE
new file mode 100644
index 0000000000..35b538308f
--- /dev/null
+++ b/ApplicationExeCode/Resources/codicons/LICENSE
@@ -0,0 +1,399 @@
+Attribution 4.0 International
+
+=======================================================================
+
+Creative Commons Corporation ("Creative Commons") is not a law firm and
+does not provide legal services or legal advice. Distribution of
+Creative Commons public licenses does not create a lawyer-client or
+other relationship. Creative Commons makes its licenses and related
+information available on an "as-is" basis. Creative Commons gives no
+warranties regarding its licenses, any material licensed under their
+terms and conditions, or any related information. Creative Commons
+disclaims all liability for damages resulting from their use to the
+fullest extent possible.
+
+Using Creative Commons Public Licenses
+
+Creative Commons public licenses provide a standard set of terms and
+conditions that creators and other rights holders may use to share
+original works of authorship and other material subject to copyright
+and certain other rights specified in the public license below. The
+following considerations are for informational purposes only, are not
+exhaustive, and do not form part of our licenses.
+
+ Considerations for licensors: Our public licenses are
+ intended for use by those authorized to give the public
+ permission to use material in ways otherwise restricted by
+ copyright and certain other rights. Our licenses are
+ irrevocable. Licensors should read and understand the terms
+ and conditions of the license they choose before applying it.
+ Licensors should also secure all rights necessary before
+ applying our licenses so that the public can reuse the
+ material as expected. Licensors should clearly mark any
+ material not subject to the license. This includes other CC-
+ licensed material, or material used under an exception or
+ limitation to copyright. More considerations for licensors:
+ wiki.creativecommons.org/Considerations_for_licensors
+
+ Considerations for the public: By using one of our public
+ licenses, a licensor grants the public permission to use the
+ licensed material under specified terms and conditions. If
+ the licensor's permission is not necessary for any reason--for
+ example, because of any applicable exception or limitation to
+ copyright--then that use is not regulated by the license. Our
+ licenses grant only permissions under copyright and certain
+ other rights that a licensor has authority to grant. Use of
+ the licensed material may still be restricted for other
+ reasons, including because others have copyright or other
+ rights in the material. A licensor may make special requests,
+ such as asking that all changes be marked or described.
+ Although not required by our licenses, you are encouraged to
+ respect those requests where reasonable. More_considerations
+ for the public:
+ wiki.creativecommons.org/Considerations_for_licensees
+
+=======================================================================
+
+Creative Commons Attribution 4.0 International Public License
+
+By exercising the Licensed Rights (defined below), You accept and agree
+to be bound by the terms and conditions of this Creative Commons
+Attribution 4.0 International Public License ("Public License"). To the
+extent this Public License may be interpreted as a contract, You are
+granted the Licensed Rights in consideration of Your acceptance of
+these terms and conditions, and the Licensor grants You such rights in
+consideration of benefits the Licensor receives from making the
+Licensed Material available under these terms and conditions.
+
+
+Section 1 -- Definitions.
+
+ a. Adapted Material means material subject to Copyright and Similar
+ Rights that is derived from or based upon the Licensed Material
+ and in which the Licensed Material is translated, altered,
+ arranged, transformed, or otherwise modified in a manner requiring
+ permission under the Copyright and Similar Rights held by the
+ Licensor. For purposes of this Public License, where the Licensed
+ Material is a musical work, performance, or sound recording,
+ Adapted Material is always produced where the Licensed Material is
+ synched in timed relation with a moving image.
+
+ b. Adapter's License means the license You apply to Your Copyright
+ and Similar Rights in Your contributions to Adapted Material in
+ accordance with the terms and conditions of this Public License.
+
+ c. Copyright and Similar Rights means copyright and/or similar rights
+ closely related to copyright including, without limitation,
+ performance, broadcast, sound recording, and Sui Generis Database
+ Rights, without regard to how the rights are labeled or
+ categorized. For purposes of this Public License, the rights
+ specified in Section 2(b)(1)-(2) are not Copyright and Similar
+ Rights.
+
+ d. Effective Technological Measures means those measures that, in the
+ absence of proper authority, may not be circumvented under laws
+ fulfilling obligations under Article 11 of the WIPO Copyright
+ Treaty adopted on December 20, 1996, and/or similar international
+ agreements.
+
+ e. Exceptions and Limitations means fair use, fair dealing, and/or
+ any other exception or limitation to Copyright and Similar Rights
+ that applies to Your use of the Licensed Material.
+
+ f. Licensed Material means the artistic or literary work, database,
+ or other material to which the Licensor applied this Public
+ License.
+
+ g. Licensed Rights means the rights granted to You subject to the
+ terms and conditions of this Public License, which are limited to
+ all Copyright and Similar Rights that apply to Your use of the
+ Licensed Material and that the Licensor has authority to license.
+
+ h. Licensor means the individual(s) or entity(ies) granting rights
+ under this Public License.
+
+ i. Share means to provide material to the public by any means or
+ process that requires permission under the Licensed Rights, such
+ as reproduction, public display, public performance, distribution,
+ dissemination, communication, or importation, and to make material
+ available to the public including in ways that members of the
+ public may access the material from a place and at a time
+ individually chosen by them.
+
+ j. Sui Generis Database Rights means rights other than copyright
+ resulting from Directive 96/9/EC of the European Parliament and of
+ the Council of 11 March 1996 on the legal protection of databases,
+ as amended and/or succeeded, as well as other essentially
+ equivalent rights anywhere in the world.
+
+ k. You means the individual or entity exercising the Licensed Rights
+ under this Public License. Your has a corresponding meaning.
+
+
+Section 2 -- Scope.
+
+ a. License grant.
+
+ 1. Subject to the terms and conditions of this Public License,
+ the Licensor hereby grants You a worldwide, royalty-free,
+ non-sublicensable, non-exclusive, irrevocable license to
+ exercise the Licensed Rights in the Licensed Material to:
+
+ a. reproduce and Share the Licensed Material, in whole or
+ in part; and
+
+ b. produce, reproduce, and Share Adapted Material.
+
+ 2. Exceptions and Limitations. For the avoidance of doubt, where
+ Exceptions and Limitations apply to Your use, this Public
+ License does not apply, and You do not need to comply with
+ its terms and conditions.
+
+ 3. Term. The term of this Public License is specified in Section
+ 6(a).
+
+ 4. Media and formats; technical modifications allowed. The
+ Licensor authorizes You to exercise the Licensed Rights in
+ all media and formats whether now known or hereafter created,
+ and to make technical modifications necessary to do so. The
+ Licensor waives and/or agrees not to assert any right or
+ authority to forbid You from making technical modifications
+ necessary to exercise the Licensed Rights, including
+ technical modifications necessary to circumvent Effective
+ Technological Measures. For purposes of this Public License,
+ simply making modifications authorized by this Section 2(a)
+ (4) never produces Adapted Material.
+
+ 5. Downstream recipients.
+
+ a. Offer from the Licensor -- Licensed Material. Every
+ recipient of the Licensed Material automatically
+ receives an offer from the Licensor to exercise the
+ Licensed Rights under the terms and conditions of this
+ Public License.
+
+ b. No downstream restrictions. You may not offer or impose
+ any additional or different terms or conditions on, or
+ apply any Effective Technological Measures to, the
+ Licensed Material if doing so restricts exercise of the
+ Licensed Rights by any recipient of the Licensed
+ Material.
+
+ 6. No endorsement. Nothing in this Public License constitutes or
+ may be construed as permission to assert or imply that You
+ are, or that Your use of the Licensed Material is, connected
+ with, or sponsored, endorsed, or granted official status by,
+ the Licensor or others designated to receive attribution as
+ provided in Section 3(a)(1)(A)(i).
+
+ b. Other rights.
+
+ 1. Moral rights, such as the right of integrity, are not
+ licensed under this Public License, nor are publicity,
+ privacy, and/or other similar personality rights; however, to
+ the extent possible, the Licensor waives and/or agrees not to
+ assert any such rights held by the Licensor to the limited
+ extent necessary to allow You to exercise the Licensed
+ Rights, but not otherwise.
+
+ 2. Patent and trademark rights are not licensed under this
+ Public License.
+
+ 3. To the extent possible, the Licensor waives any right to
+ collect royalties from You for the exercise of the Licensed
+ Rights, whether directly or through a collecting society
+ under any voluntary or waivable statutory or compulsory
+ licensing scheme. In all other cases the Licensor expressly
+ reserves any right to collect such royalties.
+
+
+Section 3 -- License Conditions.
+
+Your exercise of the Licensed Rights is expressly made subject to the
+following conditions.
+
+ a. Attribution.
+
+ 1. If You Share the Licensed Material (including in modified
+ form), You must:
+
+ a. retain the following if it is supplied by the Licensor
+ with the Licensed Material:
+
+ i. identification of the creator(s) of the Licensed
+ Material and any others designated to receive
+ attribution, in any reasonable manner requested by
+ the Licensor (including by pseudonym if
+ designated);
+
+ ii. a copyright notice;
+
+ iii. a notice that refers to this Public License;
+
+ iv. a notice that refers to the disclaimer of
+ warranties;
+
+ v. a URI or hyperlink to the Licensed Material to the
+ extent reasonably practicable;
+
+ b. indicate if You modified the Licensed Material and
+ retain an indication of any previous modifications; and
+
+ c. indicate the Licensed Material is licensed under this
+ Public License, and include the text of, or the URI or
+ hyperlink to, this Public License.
+
+ 2. You may satisfy the conditions in Section 3(a)(1) in any
+ reasonable manner based on the medium, means, and context in
+ which You Share the Licensed Material. For example, it may be
+ reasonable to satisfy the conditions by providing a URI or
+ hyperlink to a resource that includes the required
+ information.
+
+ 3. If requested by the Licensor, You must remove any of the
+ information required by Section 3(a)(1)(A) to the extent
+ reasonably practicable.
+
+ 4. If You Share Adapted Material You produce, the Adapter's
+ License You apply must not prevent recipients of the Adapted
+ Material from complying with this Public License.
+
+
+Section 4 -- Sui Generis Database Rights.
+
+Where the Licensed Rights include Sui Generis Database Rights that
+apply to Your use of the Licensed Material:
+
+ a. for the avoidance of doubt, Section 2(a)(1) grants You the right
+ to extract, reuse, reproduce, and Share all or a substantial
+ portion of the contents of the database;
+
+ b. if You include all or a substantial portion of the database
+ contents in a database in which You have Sui Generis Database
+ Rights, then the database in which You have Sui Generis Database
+ Rights (but not its individual contents) is Adapted Material; and
+
+ c. You must comply with the conditions in Section 3(a) if You Share
+ all or a substantial portion of the contents of the database.
+
+For the avoidance of doubt, this Section 4 supplements and does not
+replace Your obligations under this Public License where the Licensed
+Rights include other Copyright and Similar Rights.
+
+
+Section 5 -- Disclaimer of Warranties and Limitation of Liability.
+
+ a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
+ EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
+ AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
+ ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
+ IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
+ WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
+ PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
+ ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
+ KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
+ ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
+
+ b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
+ TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
+ NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
+ INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
+ COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
+ USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
+ ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
+ DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
+ IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
+
+ c. The disclaimer of warranties and limitation of liability provided
+ above shall be interpreted in a manner that, to the extent
+ possible, most closely approximates an absolute disclaimer and
+ waiver of all liability.
+
+
+Section 6 -- Term and Termination.
+
+ a. This Public License applies for the term of the Copyright and
+ Similar Rights licensed here. However, if You fail to comply with
+ this Public License, then Your rights under this Public License
+ terminate automatically.
+
+ b. Where Your right to use the Licensed Material has terminated under
+ Section 6(a), it reinstates:
+
+ 1. automatically as of the date the violation is cured, provided
+ it is cured within 30 days of Your discovery of the
+ violation; or
+
+ 2. upon express reinstatement by the Licensor.
+
+ For the avoidance of doubt, this Section 6(b) does not affect any
+ right the Licensor may have to seek remedies for Your violations
+ of this Public License.
+
+ c. For the avoidance of doubt, the Licensor may also offer the
+ Licensed Material under separate terms or conditions or stop
+ distributing the Licensed Material at any time; however, doing so
+ will not terminate this Public License.
+
+ d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
+ License.
+
+
+Section 7 -- Other Terms and Conditions.
+
+ a. The Licensor shall not be bound by any additional or different
+ terms or conditions communicated by You unless expressly agreed.
+
+ b. Any arrangements, understandings, or agreements regarding the
+ Licensed Material not stated herein are separate from and
+ independent of the terms and conditions of this Public License.
+
+
+Section 8 -- Interpretation.
+
+ a. For the avoidance of doubt, this Public License does not, and
+ shall not be interpreted to, reduce, limit, restrict, or impose
+ conditions on any use of the Licensed Material that could lawfully
+ be made without permission under this Public License.
+
+ b. To the extent possible, if any provision of this Public License is
+ deemed unenforceable, it shall be automatically reformed to the
+ minimum extent necessary to make it enforceable. If the provision
+ cannot be reformed, it shall be severed from this Public License
+ without affecting the enforceability of the remaining terms and
+ conditions.
+
+ c. No term or condition of this Public License will be waived and no
+ failure to comply consented to unless expressly agreed to by the
+ Licensor.
+
+ d. Nothing in this Public License constitutes or may be interpreted
+ as a limitation upon, or waiver of, any privileges and immunities
+ that apply to the Licensor or You, including from the legal
+ processes of any jurisdiction or authority.
+
+
+=======================================================================
+
+Creative Commons is not a party to its public
+licenses. Notwithstanding, Creative Commons may elect to apply one of
+its public licenses to material it publishes and in those instances
+will be considered the “Licensor.” The text of the Creative Commons
+public licenses is dedicated to the public domain under the CC0 Public
+Domain Dedication. Except for the limited purpose of indicating that
+material is shared under a Creative Commons public license or as
+otherwise permitted by the Creative Commons policies published at
+creativecommons.org/policies, Creative Commons does not authorize the
+use of the trademark "Creative Commons" or any other trademark or logo
+of Creative Commons without its prior written consent including,
+without limitation, in connection with any unauthorized modifications
+to any of its public licenses or any other arrangements,
+understandings, or agreements concerning use of licensed material. For
+the avoidance of doubt, this paragraph does not form part of the
+public licenses.
+
+Creative Commons may be contacted at creativecommons.org.
+
+---
+
+Git Logo by [Jason Long](https://bsky.app/profile/jasonlong.me) is licensed under the [Creative Commons Attribution 3.0 Unported License](https://creativecommons.org/licenses/by/3.0/).
\ No newline at end of file
diff --git a/ApplicationExeCode/Resources/codicons/README.md b/ApplicationExeCode/Resources/codicons/README.md
new file mode 100644
index 0000000000..2c78ff5f02
--- /dev/null
+++ b/ApplicationExeCode/Resources/codicons/README.md
@@ -0,0 +1,22 @@
+# Codicons
+
+The icons in this folder are from Visual Studio Code Codicons by Microsoft
+Corporation:
+
+- Source: https://github.com/microsoft/vscode-codicons (`src/icons`, commit
+ 4dc95c8e7cf691086ace683dcb5755d0190d5eb3)
+- Copyright (c) Microsoft Corporation
+- Licensed under the Creative Commons Attribution 4.0 International licence
+ (CC BY 4.0), included in [LICENSE](LICENSE) and available at
+ https://creativecommons.org/licenses/by/4.0/
+
+Changes: the fill colour `currentColor` is replaced with `#424242`.
+
+| File | Used for |
+|----------------------|-------------------------------------|
+| `symbol-string.svg` | Workflow ports of type string |
+| `symbol-numeric.svg` | Workflow ports of type int or float |
+| `check.svg` | Workflow ports of type bool |
+| `calendar.svg` | Workflow ports of type date |
+| `file.svg` | Workflow ports holding a file path |
+| `folder.svg` | Workflow ports holding a directory |
diff --git a/ApplicationExeCode/Resources/codicons/calendar.svg b/ApplicationExeCode/Resources/codicons/calendar.svg
new file mode 100644
index 0000000000..029b95b90c
--- /dev/null
+++ b/ApplicationExeCode/Resources/codicons/calendar.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ApplicationExeCode/Resources/codicons/check.svg b/ApplicationExeCode/Resources/codicons/check.svg
new file mode 100644
index 0000000000..6390117f1e
--- /dev/null
+++ b/ApplicationExeCode/Resources/codicons/check.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ApplicationExeCode/Resources/codicons/file.svg b/ApplicationExeCode/Resources/codicons/file.svg
new file mode 100644
index 0000000000..01a2a1e5b8
--- /dev/null
+++ b/ApplicationExeCode/Resources/codicons/file.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ApplicationExeCode/Resources/codicons/folder.svg b/ApplicationExeCode/Resources/codicons/folder.svg
new file mode 100644
index 0000000000..351a561b21
--- /dev/null
+++ b/ApplicationExeCode/Resources/codicons/folder.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ApplicationExeCode/Resources/codicons/symbol-numeric.svg b/ApplicationExeCode/Resources/codicons/symbol-numeric.svg
new file mode 100644
index 0000000000..4c0af16604
--- /dev/null
+++ b/ApplicationExeCode/Resources/codicons/symbol-numeric.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ApplicationExeCode/Resources/codicons/symbol-string.svg b/ApplicationExeCode/Resources/codicons/symbol-string.svg
new file mode 100644
index 0000000000..c55091bbaf
--- /dev/null
+++ b/ApplicationExeCode/Resources/codicons/symbol-string.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ApplicationLibCode/Application/RiaGuiApplication.cpp b/ApplicationLibCode/Application/RiaGuiApplication.cpp
index 848e19f1c2..d6f2b22eb9 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"
@@ -351,12 +352,29 @@ bool RiaGuiApplication::checkWithUserBeforeClose()
{
// check for any running jobs
if ( !notifyUserAboutRunningJobs() ) return false;
- // ask user to save modified project if needed
+ // Ask about workflow edits first, so that cancelling does not leave the project saved
+ if ( !askUserToDiscardWorkflowEdits() ) return false;
if ( !askUserToSaveModifiedProject() ) 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..aa4854c63d 100644
--- a/ApplicationLibCode/Commands/WorkflowCommands/CMakeLists_files.cmake
+++ b/ApplicationLibCode/Commands/WorkflowCommands/CMakeLists_files.cmake
@@ -2,6 +2,13 @@ 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
)
list(APPEND COMMAND_CODE_SOURCE_FILES ${SOURCE_GROUP_SOURCE_FILES})
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/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/RicWorkflowFeatureTools.cpp b/ApplicationLibCode/Commands/WorkflowCommands/RicWorkflowFeatureTools.cpp
new file mode 100644
index 0000000000..7d3710f413
--- /dev/null
+++ b/ApplicationLibCode/Commands/WorkflowCommands/RicWorkflowFeatureTools.cpp
@@ -0,0 +1,237 @@
+/////////////////////////////////////////////////////////////////////////////////
+//
+// 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;
+}
+
+//--------------------------------------------------------------------------------------------------
+/// 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() || workflow->isLocked() ) 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() || workflow->isLocked() ) return false;
+
+ const auto location = RicWorkflowLocationUi::askForLocation( "Save Workflow As", workflow->name() );
+ if ( !location ) return false;
+
+ const bool rename = location->name != workflow->name();
+ if ( rename )
+ {
+ 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 )
+ {
+ if ( rename ) workflow->undo();
+ 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..c0f168f501
--- /dev/null
+++ b/ApplicationLibCode/Commands/WorkflowCommands/RicWorkflowFeatureTools.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
+
+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();
+
+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..c61adb0f14
--- /dev/null
+++ b/ApplicationLibCode/Commands/WorkflowCommands/RicWorkflowLocationUi.cpp
@@ -0,0 +1,151 @@
+/////////////////////////////////////////////////////////////////////////////////
+//
+// 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. The name must be a valid workflow
+/// name. 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( RimWorkflowDefinitionTools::identifierFrom( 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;
+ }
+
+ if ( !RimWorkflowDefinitionTools::isIdentifier( ui.name() ) )
+ {
+ QMessageBox::warning( parent,
+ title,
+ QString( "'%1' is not a valid workflow name; use letters, digits and underscores, and do not "
+ "start with a digit." )
+ .arg( ui.name() ) );
+ 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..5574346949 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
@@ -16,6 +24,7 @@ set(SOURCE_GROUP_SOURCE_FILES
${CMAKE_CURRENT_LIST_DIR}/RimWorkflowDateBinding.cpp
${CMAKE_CURRENT_LIST_DIR}/RimWorkflowFilePathBinding.cpp
${CMAKE_CURRENT_LIST_DIR}/RimWorkflowVec3Binding.cpp
+ ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowMappingBinding.cpp
)
list(APPEND CODE_SOURCE_FILES ${SOURCE_GROUP_SOURCE_FILES})
diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflow.cpp
index a2b819ee78..5fa74a43de 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,671 @@ 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();
+}
+
+//--------------------------------------------------------------------------------------------------
+///
+//--------------------------------------------------------------------------------------------------
+QMap RimWorkflow::undoRenames() const
+{
+ return m_undoStack.empty() ? QMap() : invertedRenames( m_undoStack.back().renames );
+}
+
+//--------------------------------------------------------------------------------------------------
+///
+//--------------------------------------------------------------------------------------------------
+QMap RimWorkflow::redoRenames() const
+{
+ return m_redoStack.empty() ? QMap() : m_redoStack.back().renames;
+}
+
+//--------------------------------------------------------------------------------------------------
+///
+//--------------------------------------------------------------------------------------------------
+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() )
+ {
+ if ( auto copied = copyPythonModules( m_sourceDirectory, target.absolutePath() ); !copied )
+ return std::unexpected( QString( "The workflow was not saved: %1" ).arg( copied.error() ) );
+ }
+
+ 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 {};
+}
+
+//--------------------------------------------------------------------------------------------------
+/// The temporary folder that live validation writes the YAML of unsaved edits to. Runs use their
+/// own folder (see prepareRunSource()), so validation never rewrites a file a run is reading.
+//--------------------------------------------------------------------------------------------------
+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();
+}
+
+//--------------------------------------------------------------------------------------------------
+/// 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. Unsaved edits
+/// are written as workflow.yaml to `runDirectory`, a folder private to the run.
+//--------------------------------------------------------------------------------------------------
+std::expected RimWorkflow::prepareRunSource( const QString& runDirectory )
+{
+ 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() };
+ }
+
+ if ( runDirectory.isEmpty() ) return std::unexpected( "No folder to write the edited workflow to" );
+ auto output = RimWorkflowHelperProcess::runSync( { "save", "--out-dir", runDirectory, "--workflow-only" },
+ definitionJsonForSave(),
+ RimWorkflowHelperProcess::defaultTimeoutMs,
+ m_sourceDirectory );
+ if ( !output ) return std::unexpected( output.error() );
+ return RunSource{ .baseArgs = { "run", runDirectory }, .extraPythonPath = m_sourceDirectory };
+}
+
+//--------------------------------------------------------------------------------------------------
+/// Copies the `*.py` files and the package folders (with `__init__.py`), without `__pycache__`.
+/// Files that already exist in the target are kept. Fails on the first file that cannot be copied.
+//--------------------------------------------------------------------------------------------------
+std::expected RimWorkflow::copyPythonModules( const QString& sourceDirectory, const QString& targetDirectory )
+{
+ const QDir source( sourceDirectory );
+ QDir target( targetDirectory );
+ if ( !target.exists() && !QDir().mkpath( targetDirectory ) )
+ return std::unexpected( QString( "Could not create the folder '%1'" ).arg( targetDirectory ) );
+
+ for ( const QString& fileName : source.entryList( { "*.py" }, QDir::Files ) )
+ {
+ if ( target.exists( fileName ) ) continue;
+ if ( !QFile::copy( source.absoluteFilePath( fileName ), target.absoluteFilePath( fileName ) ) )
+ return std::unexpected( QString( "Could not copy '%1' to '%2'" ).arg( source.absoluteFilePath( fileName ), targetDirectory ) );
+ }
+
+ for ( const QString& folderName : source.entryList( QDir::Dirs | QDir::NoDotAndDotDot ) )
+ {
+ if ( folderName == "__pycache__" || !QFileInfo::exists( source.absoluteFilePath( folderName + "/__init__.py" ) ) ) continue;
+ if ( auto copied = copyPythonModules( source.absoluteFilePath( folderName ), target.absoluteFilePath( folderName ) ); !copied )
+ return copied;
+ }
+ return {};
}
//--------------------------------------------------------------------------------------------------
@@ -256,15 +845,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..74a4561d90 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,103 @@ 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;
+
+ // Task renames (old name -> new name) that undo() and redo() would apply
+ QMap undoRenames() const;
+ QMap redoRenames() const;
+ void undo();
+ void redo();
+
+ // Saving and running
+ std::expected save();
+ QStringList saveInPlaceWarnings() const;
+ std::expected saveAs( const QString& directory );
+ std::expected editDirectory();
+ QByteArray definitionJsonForSave() const;
+ std::expected prepareRunSource( const QString& runDirectory );
+ 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();
+ // Copy the Python modules and packages of a workflow folder. Existing files are kept.
+ static std::expected copyPythonModules( const QString& sourceDirectory, const QString& targetDirectory );
+
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;
};
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..c77009eb05 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,13 @@ RimWorkflowCollection::RimWorkflowCollection()
CAF_PDM_InitObject( "Workflows", ":/Folder.png" );
CAF_PDM_InitFieldNoDefault( &m_items, "Workflows", "" );
- rescanWorkflows();
+ CAF_PDM_InitFieldNoDefault( &m_installed, "Installed", "" );
+ m_installed = new RimWorkflowInstalledCollection;
}
+//--------------------------------------------------------------------------------------------------
+///
+//--------------------------------------------------------------------------------------------------
RimWorkflowCollection::~RimWorkflowCollection() = default;
//--------------------------------------------------------------------------------------------------
@@ -47,25 +58,103 @@ QString RimWorkflowCollection::discoveryDirectory()
}
//--------------------------------------------------------------------------------------------------
-///
+/// Reload the task catalog, the workflow folders and the installed workflows. Workflows that
+/// have never been saved are kept. This runs the Python helper, so it is not done in the
+/// constructor: projects are also created in console mode and by code generation.
//--------------------------------------------------------------------------------------------------
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..f29bf4ec6e
--- /dev/null
+++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDefinition.cpp
@@ -0,0 +1,147 @@
+/////////////////////////////////////////////////////////////////////////////////
+//
+// 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"
+
+#include "RimWorkflowPortCompatibility.h"
+
+//--------------------------------------------------------------------------------------------------
+///
+//--------------------------------------------------------------------------------------------------
+bool RimWorkflowDefinitionEdge::isWholeInput() const
+{
+ return input.isEmpty();
+}
+
+//--------------------------------------------------------------------------------------------------
+///
+//--------------------------------------------------------------------------------------------------
+bool RimWorkflowDefinitionEdge::isWholeOutput() const
+{
+ return output.isEmpty();
+}
+
+//--------------------------------------------------------------------------------------------------
+///
+//--------------------------------------------------------------------------------------------------
+bool RimWorkflowDefinitionEdge::isCollected() const
+{
+ return collect != Collect::None;
+}
+
+//--------------------------------------------------------------------------------------------------
+/// Same tasks and ports, ignoring how the value is collected
+//--------------------------------------------------------------------------------------------------
+bool RimWorkflowDefinitionEdge::sameConnection( const RimWorkflowDefinitionEdge& other ) const
+{
+ return from == other.from && output == other.output && to == other.to && input == other.input;
+}
+
+//--------------------------------------------------------------------------------------------------
+///
+//--------------------------------------------------------------------------------------------------
+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();
+}
+
+//--------------------------------------------------------------------------------------------------
+/// The task type as seen by the workflow: a mapped task has `dict[str, Output]` as output, and its
+/// key and value fields are not inputs
+//--------------------------------------------------------------------------------------------------
+QJsonObject RimWorkflowDefinition::taskTypeForNode( const QString& nodeName ) const
+{
+ const auto* node = findNode( nodeName );
+ if ( !node ) return {};
+ const QJsonObject type = taskType( node->taskId );
+ if ( type.isEmpty() || !node->map ) return type;
+ return RimWorkflowPortCompatibility::mappedTaskType( type, *node->map );
+}
+
+//--------------------------------------------------------------------------------------------------
+///
+//--------------------------------------------------------------------------------------------------
+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..f182544bf3
--- /dev/null
+++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDefinition.h
@@ -0,0 +1,151 @@
+/////////////////////////////////////////////////////////////////////////////////
+//
+// 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
+
+//==================================================================================================
+/// A mapped task (`map:` in YAML) runs once per entry of the job config value `over`, a mapping
+/// with string keys. Each key goes to the input field `keyAs` and each value to `valueAs`.
+/// The output is `dict[str, Output]` keyed like `over`.
+//==================================================================================================
+struct RimWorkflowTaskMap
+{
+ QString over{};
+ QString keyAs{};
+ QString valueAs{};
+ QString errorMode = "fail_fast";
+
+ bool operator==( const RimWorkflowTaskMap& other ) const = default;
+};
+
+//==================================================================================================
+/// 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{};
+
+ std::optional map{};
+
+ // Keys of the YAML entry the editor does not model (nested workflow); 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.
+/// Several edges can collect outputs into one `list[T]` or `dict[str, T]` input (`collect:` in
+/// YAML). List members are ordered like the edges; dict members have a key.
+//==================================================================================================
+struct RimWorkflowDefinitionEdge
+{
+ enum class Collect
+ {
+ None,
+ List,
+ Dict
+ };
+
+ QString from{};
+ QString output{};
+ QString to{};
+ QString input{};
+ Collect collect = Collect::None;
+ QString key{};
+
+ bool isWholeInput() const;
+ bool isWholeOutput() const;
+ bool isCollected() const;
+ bool sameConnection( const RimWorkflowDefinitionEdge& other ) 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..844dced665
--- /dev/null
+++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDefinitionTools.cpp
@@ -0,0 +1,1205 @@
+/////////////////////////////////////////////////////////////////////////////////
+//
+// 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
+#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;
+ if ( json.value( "map" ).isObject() )
+ {
+ const QJsonObject map = json.value( "map" ).toObject();
+ node.map = RimWorkflowTaskMap{ .over = map.value( "over" ).toString(),
+ .keyAs = map.value( "key_as" ).toString(),
+ .valueAs = map.value( "value_as" ).toString(),
+ .errorMode = map.value( "error_mode" ).toString( "fail_fast" ) };
+ }
+ for ( auto it = json.begin(); it != json.end(); ++it )
+ {
+ if ( it.key() != "name" && it.key() != "task" && it.key() != "config_fields" && it.key() != explicitConfigKey && it.key() != "map" )
+ node.extra.insert( it.key(), it.value() );
+ }
+ return node;
+}
+
+QJsonObject mapToJson( const RimWorkflowTaskMap& map )
+{
+ return QJsonObject{ { "over", map.over }, { "key_as", map.keyAs }, { "value_as", map.valueAs }, { "error_mode", map.errorMode } };
+}
+
+QString collectName( RimWorkflowDefinitionEdge::Collect collect )
+{
+ switch ( collect )
+ {
+ case RimWorkflowDefinitionEdge::Collect::List:
+ return "list";
+ case RimWorkflowDefinitionEdge::Collect::Dict:
+ return "dict";
+ default:
+ return {};
+ }
+}
+
+RimWorkflowDefinitionEdge edgeFromJson( const QJsonObject& json )
+{
+ RimWorkflowDefinitionEdge edge{ .from = json.value( "from" ).toString(),
+ .output = json.value( "output" ).toString(),
+ .to = json.value( "to" ).toString(),
+ .input = json.value( "input" ).toString() };
+ const QString collect = json.value( "collect" ).toString();
+ if ( collect == "list" ) edge.collect = RimWorkflowDefinitionEdge::Collect::List;
+ if ( collect == "dict" )
+ {
+ edge.collect = RimWorkflowDefinitionEdge::Collect::Dict;
+ edge.key = json.value( "key" ).toString();
+ }
+ return edge;
+}
+
+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 );
+ if ( node.map ) json["map"] = mapToJson( *node.map );
+ return json;
+}
+
+QJsonObject edgeToJson( const RimWorkflowDefinitionEdge& edge )
+{
+ QJsonObject json{ { "from", edge.from }, { "output", nullIfEmpty( edge.output ) }, { "to", edge.to }, { "input", nullIfEmpty( edge.input ) } };
+ if ( edge.isCollected() )
+ {
+ json["collect"] = collectName( edge.collect );
+ json["key"] = edge.collect == RimWorkflowDefinitionEdge::Collect::Dict ? nullIfEmpty( edge.key ) : QJsonValue( QJsonValue::Null );
+ }
+ return json;
+}
+
+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;
+ QMap collectKinds;
+ QMap collectKeys;
+ for ( const auto& edge : incoming )
+ {
+ if ( edge.isWholeInput() )
+ {
+ ++wholeCount;
+ continue;
+ }
+ if ( edge.isCollected() )
+ {
+ const QString kind = collectName( edge.collect );
+ if ( fieldInputs.contains( edge.input ) && !collectKinds.contains( edge.input ) )
+ addIssue( issues,
+ node.name,
+ edge.input,
+ QString( "Input '%1' has both a direct connection and collected members" ).arg( edge.input ) );
+ if ( collectKinds.contains( edge.input ) && collectKinds.value( edge.input ) != kind )
+ addIssue( issues, node.name, edge.input, QString( "Input '%1' mixes list and keyed members" ).arg( edge.input ) );
+ collectKinds.insert( edge.input, kind );
+ if ( edge.collect == RimWorkflowDefinitionEdge::Collect::Dict )
+ {
+ if ( edge.key.isEmpty() )
+ addIssue( issues, node.name, edge.input, QString( "A member of input '%1' has no key" ).arg( edge.input ) );
+ else if ( collectKeys[edge.input].contains( edge.key ) )
+ addIssue( issues, node.name, edge.input, QString( "Duplicate key '%1' in input '%2'" ).arg( edge.key, edge.input ) );
+ collectKeys[edge.input].append( edge.key );
+ }
+ fieldInputs.insert( edge.input );
+ continue;
+ }
+ if ( collectKinds.contains( edge.input ) )
+ addIssue( issues, node.name, edge.input, QString( "Input '%1' has both a direct connection and collected members" ).arg( edge.input ) );
+ else 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;
+ if ( edge.isCollected() )
+ {
+ auto check = RimWorkflowPortCompatibility::checkCollectMember( definition, edge );
+ if ( !check ) addIssue( issues, node.name, edge.input, check.error() );
+ continue;
+ }
+ RimWorkflowDefinition others = definition;
+ std::erase( others.edges, edge );
+ auto check = RimWorkflowPortCompatibility::resolveConnection( others, edge.from, edge.output, edge.to, edge.input );
+ if ( !check )
+ addIssue( issues, node.name, edge.input, check.error() );
+ else if ( check->isCollected() )
+ addIssue( issues,
+ node.name,
+ edge.input,
+ QString( "%1 is a single member of input '%2'; it must be collected" ).arg( edge.from, edge.input ) );
+ }
+}
+
+void validateMap( const RimWorkflowDefinition& definition, const RimWorkflowDefinitionNode& node, std::vector& issues )
+{
+ if ( !node.map ) return;
+ auto check = RimWorkflowDefinitionTools::checkTaskMap( definition, node.name, *node.map );
+ if ( !check ) addIssue( issues, node.name, node.map->over, check.error() );
+}
+
+void validateInputs( const RimWorkflowDefinition& definition, const RimWorkflowDefinitionNode& node, std::vector& issues )
+{
+ const QJsonObject taskType = definition.taskTypeForNode( node.name );
+ 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 && !node.map )
+ {
+ 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;
+ QJsonObject json{ { "name", port.name },
+ { "type", port.typeName },
+ { "icon", port.iconResource },
+ { "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 } };
+ if ( const auto target = RimWorkflowPortCompatibility::collectTarget( port ) ) json["collect"] = collectName( target->first );
+ result.append( json );
+ }
+ 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 },
+ { "icon", port.iconResource },
+ { "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.taskTypeForNode( node.name );
+ 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 ) );
+
+ // The mapping a mapped task runs over is a config value, shown as an input of the task
+ QJsonArray inputPortList = inputPortsJson( definition, node, inputPorts, definition.incomingEdges( node.name ) );
+ QJsonObject inputTypes = RimWorkflowPortCompatibility::portTypes( inputPorts );
+ QJsonObject inputIcons = RimWorkflowPortCompatibility::portIcons( inputPorts );
+ QJsonObject mapJson;
+ if ( node.map )
+ {
+ const QJsonObject overSchema = RimWorkflowDefinitionTools::mapOverSchema( definition, node.name );
+ configFields.append( overSchema );
+ mapJson = mapToJson( *node.map );
+ mapJson["type"] = overSchema.value( "type_name" ).toString();
+ inputTypes[node.map->over] = overSchema.value( "type_name" ).toString();
+ if ( overSchema.contains( "icon" ) ) inputIcons[node.map->over] = overSchema.value( "icon" ).toString();
+ inputPortList.prepend( QJsonObject{ { "name", node.map->over },
+ { "type", overSchema.value( "type_name" ).toString() },
+ { "icon", overSchema.value( "icon" ).toString() },
+ { "description", overSchema.value( "description" ).toString() },
+ { "required", true },
+ { "configurable", true },
+ { "configured", true },
+ { "covered", false },
+ { "wired", false },
+ { "map_over", true } } );
+ }
+
+ 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", inputPortList },
+ { "output_ports", outputPortsJson( outputPorts, definition.outgoingEdges( node.name ) ) },
+ { "input_types", inputTypes },
+ { "output_types", RimWorkflowPortCompatibility::portTypes( outputPorts ) },
+ { "input_icons", inputIcons },
+ { "output_icons", RimWorkflowPortCompatibility::portIcons( outputPorts ) },
+ { "map", mapJson.isEmpty() ? QJsonValue( QJsonValue::Null ) : QJsonValue( mapJson ) },
+ { "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() )
+ definition.edges.push_back( edgeFromJson( value.toObject() ) );
+ 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). 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.taskTypeForNode( nodeName );
+ 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 ) || ( node->map && field == node->map->over ) ) 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, except when the new
+/// connection is another member of a collected (list or dict) input.
+//--------------------------------------------------------------------------------------------------
+RimWorkflowDefinitionTools::EditResult RimWorkflowDefinitionTools::connect( const RimWorkflowDefinition& definition,
+ const RimWorkflowDefinitionEdge& edge )
+{
+ if ( !definition.editable ) return std::unexpected( "The workflow is read-only" );
+
+ auto resolved = RimWorkflowPortCompatibility::resolveConnection( definition, edge.from, edge.output, edge.to, edge.input );
+ if ( !resolved ) return std::unexpected( resolved.error() );
+
+ RimWorkflowDefinition result = definition;
+ std::erase_if( result.edges,
+ [&resolved]( const RimWorkflowDefinitionEdge& existing )
+ {
+ return existing.to == resolved->to && existing.input == resolved->input &&
+ ( !resolved->isCollected() || existing.collect != resolved->collect );
+ } );
+ result.edges.push_back( *resolved );
+ 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_if( result.edges, [&edge]( const RimWorkflowDefinitionEdge& existing ) { return existing.sameConnection( edge ); } ) == 0 )
+ return std::unexpected( "The connection does not exist" );
+ return normalize( result );
+}
+
+//--------------------------------------------------------------------------------------------------
+/// Rename the key of a member of a `dict[str, T]` input
+//--------------------------------------------------------------------------------------------------
+RimWorkflowDefinitionTools::EditResult RimWorkflowDefinitionTools::setCollectKey( const RimWorkflowDefinition& definition,
+ const RimWorkflowDefinitionEdge& edge,
+ const QString& key )
+{
+ if ( !definition.editable ) return std::unexpected( "The workflow is read-only" );
+ const QString trimmedKey = key.trimmed();
+ if ( trimmedKey.isEmpty() ) return std::unexpected( "The key cannot be empty" );
+
+ RimWorkflowDefinition result = definition;
+ auto it =
+ std::find_if( result.edges.begin(), result.edges.end(), [&edge]( const auto& existing ) { return existing.sameConnection( edge ); } );
+ if ( it == result.edges.end() ) return std::unexpected( "The connection does not exist" );
+ if ( it->collect != RimWorkflowDefinitionEdge::Collect::Dict ) return std::unexpected( "The connection is not a keyed member" );
+
+ for ( const auto& other : result.edges )
+ {
+ if ( &other != &*it && other.collect == RimWorkflowDefinitionEdge::Collect::Dict && other.to == it->to &&
+ other.input == it->input && other.key == trimmedKey )
+ return std::unexpected( QString( "Input '%1' already has a member with key '%2'" ).arg( it->input, trimmedKey ) );
+ }
+ it->key = trimmedKey;
+ return result;
+}
+
+//--------------------------------------------------------------------------------------------------
+/// Move a member of a `list[T]` input `delta` positions within the list
+//--------------------------------------------------------------------------------------------------
+RimWorkflowDefinitionTools::EditResult
+ RimWorkflowDefinitionTools::moveCollectMember( const RimWorkflowDefinition& definition, const RimWorkflowDefinitionEdge& edge, int delta )
+{
+ if ( !definition.editable ) return std::unexpected( "The workflow is read-only" );
+
+ RimWorkflowDefinition result = definition;
+ std::vector members;
+ size_t position = 0;
+ bool found = false;
+ for ( size_t i = 0; i < result.edges.size(); ++i )
+ {
+ const auto& existing = result.edges[i];
+ if ( existing.to != edge.to || existing.input != edge.input || !existing.isCollected() ) continue;
+ if ( existing.sameConnection( edge ) )
+ {
+ position = members.size();
+ found = true;
+ }
+ members.push_back( i );
+ }
+ if ( !found ) return std::unexpected( "The connection is not a collected member" );
+
+ const int target = static_cast( position ) + delta;
+ if ( target < 0 || target >= static_cast( members.size() ) ) return std::unexpected( "The member cannot be moved further" );
+ std::swap( result.edges[members[position]], result.edges[members[static_cast( target )]] );
+ return result;
+}
+
+//--------------------------------------------------------------------------------------------------
+/// Checks that a map fits the task: the key and value fields are inputs of the task, and `over`
+/// is a new config field name
+//--------------------------------------------------------------------------------------------------
+std::expected
+ RimWorkflowDefinitionTools::checkTaskMap( const RimWorkflowDefinition& definition, const QString& nodeName, const RimWorkflowTaskMap& map )
+{
+ const auto* node = definition.findNode( nodeName );
+ if ( !node ) return std::unexpected( QString( "Unknown task '%1'" ).arg( nodeName ) );
+
+ if ( !isIdentifier( map.over ) ) return std::unexpected( QString( "'%1' is not a valid name for the mapping" ).arg( map.over ) );
+ if ( map.keyAs.isEmpty() || map.valueAs.isEmpty() ) return std::unexpected( "Choose the inputs that receive the key and the value" );
+ if ( map.keyAs == map.valueAs ) return std::unexpected( "The same input cannot receive both the key and the value" );
+ if ( map.errorMode != "fail_fast" && map.errorMode != "collect_all" )
+ return std::unexpected( QString( "Unknown error mode '%1'" ).arg( map.errorMode ) );
+
+ const QJsonObject taskType = definition.taskType( node->taskId );
+ if ( taskType.isEmpty() ) return {};
+
+ const QJsonObject inputSchema = taskType.value( "input_schema" ).toObject();
+ const QJsonObject properties = inputSchema.value( "properties" ).toObject();
+ for ( const QString& field : { map.keyAs, map.valueAs } )
+ {
+ if ( !properties.contains( field ) ) return std::unexpected( QString( "'%1' has no input '%2'" ).arg( nodeName, field ) );
+ }
+ if ( properties.contains( map.over ) )
+ return std::unexpected( QString( "'%1' is an input of '%2'; choose another name for the mapping" ).arg( map.over, nodeName ) );
+
+ const QJsonObject keySchema = properties.value( map.keyAs ).toObject();
+ const QJsonObject strSchema{ { "type", "string" } };
+ if ( !RimWorkflowPortCompatibility::isCompatible( strSchema, strSchema, keySchema, inputSchema ) )
+ return std::unexpected( QString( "Input '%1' receives the key and must accept a string" ).arg( map.keyAs ) );
+ return {};
+}
+
+//--------------------------------------------------------------------------------------------------
+/// Config field schema of the mapping a mapped task runs over: a `dict[str, Value]`, where the
+/// value type is the type of the `value_as` input
+//--------------------------------------------------------------------------------------------------
+QJsonObject RimWorkflowDefinitionTools::mapOverSchema( const RimWorkflowDefinition& definition, const QString& nodeName )
+{
+ const auto* node = definition.findNode( nodeName );
+ if ( !node || !node->map ) return {};
+
+ const QJsonObject inputSchema = definition.taskType( node->taskId ).value( "input_schema" ).toObject();
+ const QJsonObject configValues = definition.inputs.value( nodeName ).toObject();
+
+ QJsonObject entry{ { "name", node->map->over }, { "type", "object" }, { "required", true }, { "map_over", true } };
+ if ( configValues.contains( node->map->over ) ) entry["default"] = configValues.value( node->map->over );
+
+ QString valueType = "Any";
+ if ( !node->map->valueAs.isEmpty() )
+ {
+ const QJsonObject value = RimWorkflowSchemaTools::configFieldSchema( node->map->valueAs, inputSchema, {}, {} );
+ entry["value_type"] = value.value( "type" );
+ if ( value.contains( "resinsight_type" ) ) entry["value_resinsight_type"] = value.value( "resinsight_type" );
+ if ( value.contains( "format" ) ) entry["value_format"] = value.value( "format" );
+
+ const QJsonObject property = inputSchema.value( "properties" ).toObject().value( node->map->valueAs ).toObject();
+ valueType = RimWorkflowPortCompatibility::typeName( property, inputSchema );
+ const QString icon = RimWorkflowPortCompatibility::iconResource( property, inputSchema );
+ if ( !icon.isEmpty() ) entry["icon"] = icon;
+ }
+ entry["type_name"] = QString( "dict[str, %1]" ).arg( valueType );
+ entry["description"] =
+ QString( "The items '%1' runs for; each key is passed as '%2' and each value as '%3'" ).arg( nodeName, node->map->keyAs, node->map->valueAs );
+ return entry;
+}
+
+//--------------------------------------------------------------------------------------------------
+/// Run a task once per item of a mapping, or remove the map with std::nullopt
+//--------------------------------------------------------------------------------------------------
+RimWorkflowDefinitionTools::EditResult RimWorkflowDefinitionTools::setTaskMap( const RimWorkflowDefinition& definition,
+ const QString& nodeName,
+ const std::optional& map )
+{
+ 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 ) );
+
+ RimWorkflowDefinition result = definition;
+ auto* target = result.findNode( nodeName );
+ QJsonObject values = result.inputs.value( nodeName ).toObject();
+ if ( node->map ) values.remove( node->map->over );
+
+ if ( map )
+ {
+ if ( auto check = checkTaskMap( definition, nodeName, *map ); !check ) return std::unexpected( check.error() );
+ for ( const auto& edge : definition.incomingEdges( nodeName ) )
+ {
+ if ( edge.isWholeInput() )
+ return std::unexpected(
+ QString( "'%1' takes its whole input from '%2'; disconnect it before mapping" ).arg( nodeName, edge.from ) );
+ }
+
+ // The key and value inputs are filled by the map
+ std::erase_if( result.edges,
+ [&]( const RimWorkflowDefinitionEdge& edge )
+ { return edge.to == nodeName && ( edge.input == map->keyAs || edge.input == map->valueAs ); } );
+ target->explicitConfigFields.removeAll( map->keyAs );
+ target->explicitConfigFields.removeAll( map->valueAs );
+ values.remove( map->keyAs );
+ values.remove( map->valueAs );
+ // An edited map keeps the items it runs over, also when `over` is renamed
+ const QJsonObject oldValues = definition.inputs.value( nodeName ).toObject();
+ if ( node->map && oldValues.contains( node->map->over ) ) values[map->over] = oldValues.value( node->map->over );
+ }
+ target->map = map;
+ result.inputs.insert( nodeName, values );
+
+ // The output type changes to or from dict[str, Output]. Downstream connections are kept, and
+ // validate() reports those that no longer fit; normalize() derives the config fields.
+ result = normalize( result );
+ return 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.taskTypeForNode( nodeName ), 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.taskTypeForNode( nodeName ) );
+ 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 );
+ validateMap( 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 ) );
+
+ // Position of each list member within its input
+ QMap listIndex;
+ QMap listCount;
+ for ( const auto& edge : definition.edges )
+ {
+ if ( edge.collect == RimWorkflowDefinitionEdge::Collect::List ) listIndex[&edge] = listCount[edge.to + "." + edge.input]++;
+ }
+
+ 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;
+ if ( edge.isCollected() )
+ {
+ json["collect"] = collectName( edge.collect );
+ json["label"] = edge.collect == RimWorkflowDefinitionEdge::Collect::Dict ? edge.key : QString( "[%1]" ).arg( listIndex[&edge] );
+ }
+ 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..b3c1f13cea
--- /dev/null
+++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDefinitionTools.h
@@ -0,0 +1,89 @@
+/////////////////////////////////////////////////////////////////////////////////
+//
+// 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
+#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 setCollectKey( const RimWorkflowDefinition& definition, const RimWorkflowDefinitionEdge& edge, const QString& key );
+EditResult moveCollectMember( const RimWorkflowDefinition& definition, const RimWorkflowDefinitionEdge& edge, int delta );
+EditResult setTaskMap( const RimWorkflowDefinition& definition, const QString& nodeName, const std::optional& map );
+std::expected checkTaskMap( const RimWorkflowDefinition& definition, const QString& nodeName, const RimWorkflowTaskMap& map );
+QJsonObject mapOverSchema( const RimWorkflowDefinition& definition, const QString& nodeName );
+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..495718df84 100644
--- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDescribeTools.cpp
+++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDescribeTools.cpp
@@ -18,157 +18,15 @@
#include "RimWorkflowDescribeTools.h"
+#include "RimWorkflowPortCompatibility.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;
-}
-
-//--------------------------------------------------------------------------------------------------
-///
-//--------------------------------------------------------------------------------------------------
void appendEdge( QJsonArray& edges, const QJsonObject& ref, const QString& to, const QString& input )
{
const QString from = ref.value( "task" ).toString();
@@ -236,7 +94,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,13 +109,17 @@ 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() },
{ "inputs", inputs },
{ "outputs", outputs },
- { "config_fields", configFields } };
+ { "config_fields", configFields },
+ { "input_types", RimWorkflowPortCompatibility::portTypes( RimWorkflowPortCompatibility::inputPorts( task ) ) },
+ { "output_types", RimWorkflowPortCompatibility::portTypes( RimWorkflowPortCompatibility::outputPorts( task ) ) },
+ { "input_icons", RimWorkflowPortCompatibility::portIcons( RimWorkflowPortCompatibility::inputPorts( task ) ) },
+ { "output_icons", RimWorkflowPortCompatibility::portIcons( RimWorkflowPortCompatibility::outputPorts( task ) ) } };
}
} // namespace
@@ -304,17 +166,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..ae31e37300
--- /dev/null
+++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowHelperProcess.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 "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 )
+{
+ if ( const auto invalid = invalidResult( standardOutput ) )
+ return std::unexpected( errorMessage( invalid->value( "error" ).toObject() ) );
+
+ QJsonParseError parseError{};
+ const QJsonDocument document = QJsonDocument::fromJson( standardOutput.trimmed(), &parseError );
+ if ( parseError.error == QJsonParseError::NoError && document.isObject() && exitCode == 0 ) return document.object();
+
+ 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 );
+}
+
+//--------------------------------------------------------------------------------------------------
+///
+//--------------------------------------------------------------------------------------------------
+std::optional RimWorkflowHelperProcess::invalidResult( const QByteArray& standardOutput )
+{
+ QJsonParseError parseError{};
+ const QJsonDocument document = QJsonDocument::fromJson( standardOutput.trimmed(), &parseError );
+ if ( parseError.error != QJsonParseError::NoError || !document.isObject() ) return std::nullopt;
+ if ( document.object().value( "status" ).toString() != "invalid" ) return std::nullopt;
+ return document.object();
+}
+
+//--------------------------------------------------------------------------------------------------
+/// 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..36a562858b
--- /dev/null
+++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowHelperProcess.h
@@ -0,0 +1,48 @@
+/////////////////////////////////////////////////////////////////////////////////
+//
+// Copyright (C) 2026- Equinor ASA
+//
+// ResInsight is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY
+// WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE.
+//
+// See the GNU General Public License at
+// for more details.
+//
+/////////////////////////////////////////////////////////////////////////////////
+
+#pragma once
+
+#include
+#include
+#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 );
+
+// The `{"status": "invalid", "error": {...}}` document printed on stdout, if any. parseOutput()
+// reduces it to a message; this keeps the task and field the error refers to.
+std::optional invalidResult( const QByteArray& standardOutput );
+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..ed178cdc33 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() )
{
@@ -212,6 +212,29 @@ void RimWorkflowJob::updateTaskState( const QString& runId, const QString& taskN
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
+QMap> RimWorkflowJob::itemStates() const
+{
+ return m_itemStates;
+}
+
+QMap> RimWorkflowJob::itemErrors() const
+{
+ return m_itemErrors;
+}
+
+void RimWorkflowJob::updateItemState( const QString& runId, const QString& taskName, const QString& item, const QString& state, const QString& error )
+{
+ if ( runId != m_runId || !m_taskStates.contains( taskName ) ) return;
+ if ( state != "running" && state != "completed" && state != "failed" ) return;
+
+ m_itemStates[taskName][item] = state;
+ if ( state == "failed" )
+ m_itemErrors[taskName][item] = error;
+ else if ( m_itemErrors.contains( taskName ) )
+ m_itemErrors[taskName].remove( item );
+ if ( auto* window = RiuMainWindow::instance() ) window->workflowJobStateChanged( this );
+}
+
void RimWorkflowJob::finishRun( const QString& runId, bool succeeded, bool cancelled )
{
if ( runId != m_runId ) return;
@@ -226,7 +249,7 @@ void RimWorkflowJob::finishRun( const QString& runId, bool succeeded, bool cance
if ( cancelled )
m_runStatus = "Cancelled";
else if ( succeeded )
- m_runStatus = "Completed";
+ m_runStatus.clear();
else if ( !m_runStatus.startsWith( "Failed:" ) )
m_runStatus = "Failed (see workflow log)";
m_activeTask.clear();
@@ -244,17 +267,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 +415,6 @@ void RimWorkflowJob::runJob()
return;
}
- const QString dir = workflow->workflowDirectory();
- if ( dir.isEmpty() ) return;
-
auto port = RiaApplication::instance()->activeGrpcPortNumber();
if ( !port.has_value() )
{
@@ -301,6 +436,13 @@ void RimWorkflowJob::runJob()
RiaLogging::warning( "Cannot create temp dir for workflow run." );
return;
}
+
+ auto runSource = workflow->prepareRunSource( tmp.absoluteFilePath( runDir ) );
+ if ( !runSource )
+ {
+ RiaLogging::warning( QString( "Cannot run workflow: %1" ).arg( runSource.error() ).toStdString() );
+ return;
+ }
QString inputPath = tmp.absoluteFilePath( runDir + "/input.yaml" );
if ( writeInputYaml( inputPath ).isEmpty() )
{
@@ -311,6 +453,8 @@ void RimWorkflowJob::runJob()
m_runId = QUuid::createUuid().toString( QUuid::WithoutBraces );
m_taskStates.clear();
m_taskErrors.clear();
+ m_itemStates.clear();
+ m_itemErrors.clear();
for ( const QJsonValue& value : workflow->graph().value( "tasks" ).toArray() )
{
const QString taskName = value.toObject().value( "name" ).toString();
@@ -320,9 +464,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() );
@@ -335,6 +480,14 @@ void RimWorkflowJob::runJob()
{
if ( safeJob && eventRunId == runId ) safeJob->updateTaskState( runId, taskName, state, error );
} );
+ QObject::connect( m_runner,
+ &RiuWorkflowJobRunner::mapItemStateChanged,
+ m_runner,
+ [safeJob,
+ runId]( const QString& eventRunId, const QString& taskName, const QString& item, const QString& state, const QString& error )
+ {
+ if ( safeJob && eventRunId == runId ) safeJob->updateItemState( runId, taskName, item, state, error );
+ } );
QObject::connect( m_runner,
&RiuWorkflowJobRunner::runFinished,
m_runner,
diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowJob.h
index 68ced071c0..17527ddb0e 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,18 +43,27 @@ 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;
QString runStatus() const;
void updateTaskState( const QString& runId, const QString& taskName, const QString& state, const QString& error );
- void finishRun( const QString& runId, bool succeeded, bool cancelled );
+
+ // States and errors of the items of mapped tasks, by task and item key
+ QMap> itemStates() const;
+ QMap> itemErrors() const;
+ void updateItemState( const QString& runId, const QString& taskName, const QString& item, const QString& state, const QString& error );
+ void finishRun( const QString& runId, bool succeeded, bool cancelled );
protected:
void fieldChangedByUi( const caf::PdmFieldHandle* changedField, const QVariant& oldValue, const QVariant& newValue ) override;
@@ -64,8 +76,11 @@ private:
caf::PdmField m_name;
caf::PdmChildArrayField m_taskInputs;
QPointer m_runner;
+ QMap m_detachedValues;
QMap m_taskStates;
QMap m_taskErrors;
+ QMap> m_itemStates;
+ QMap> m_itemErrors;
QString m_runStatus;
QString m_activeTask;
QString m_runId;
diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowMappingBinding.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowMappingBinding.cpp
new file mode 100644
index 0000000000..a9c94ed796
--- /dev/null
+++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowMappingBinding.cpp
@@ -0,0 +1,240 @@
+/////////////////////////////////////////////////////////////////////////////////
+//
+// 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 "RimWorkflowMappingBinding.h"
+
+#include "RimEclipseCase.h"
+#include "RimEclipseCaseTools.h"
+#include "RimEclipseView.h"
+#include "RimProject.h"
+#include "RimTools.h"
+#include "RimWellPath.h"
+
+#include "cafPdmUiTreeSelectionEditor.h"
+
+#include
+#include
+#include
+
+CAF_PDM_SOURCE_INIT( RimWorkflowMappingBinding, "WorkflowMappingBinding" );
+
+//--------------------------------------------------------------------------------------------------
+///
+//--------------------------------------------------------------------------------------------------
+RimWorkflowMappingBinding::RimWorkflowMappingBinding()
+{
+ CAF_PDM_InitFieldNoDefault( &m_valueType, "ValueType", "Value Type" );
+ m_valueType.uiCapability()->setUiHidden( true );
+
+ CAF_PDM_InitFieldNoDefault( &m_text, "Text", "Items" );
+ m_text.uiCapability()->setUiToolTip( "A JSON object, for example {\"low\": 0.5, \"high\": 2.0}" );
+
+ CAF_PDM_InitFieldNoDefault( &m_cases, "Cases", "Cases" );
+ CAF_PDM_InitFieldNoDefault( &m_wellPaths, "WellPaths", "Well Paths" );
+ CAF_PDM_InitFieldNoDefault( &m_views, "Views", "Views" );
+ for ( caf::PdmFieldHandle* field : std::initializer_list{ &m_cases, &m_wellPaths, &m_views } )
+ {
+ field->uiCapability()->setUiEditorTypeName( caf::PdmUiTreeSelectionEditor::uiEditorTypeName() );
+ field->uiCapability()->setUiLabelPosition( caf::PdmUiItemInfo::LabelPosition::TOP );
+ }
+}
+
+//--------------------------------------------------------------------------------------------------
+///
+//--------------------------------------------------------------------------------------------------
+caf::PdmFieldHandle* RimWorkflowMappingBinding::valueField()
+{
+ if ( m_valueType() == "EclipseCase" ) return &m_cases;
+ if ( m_valueType() == "WellPath" ) return &m_wellPaths;
+ if ( m_valueType() == "View" ) return &m_views;
+ return &m_text;
+}
+
+//--------------------------------------------------------------------------------------------------
+///
+//--------------------------------------------------------------------------------------------------
+bool RimWorkflowMappingBinding::isObjectReference() const
+{
+ return !m_valueType().isEmpty();
+}
+
+//--------------------------------------------------------------------------------------------------
+/// The selected objects as `{key: reference}`, keyed by their names made unique
+//--------------------------------------------------------------------------------------------------
+std::vector