From e6dc62a16017abce579de9549665be093ced1e52 Mon Sep 17 00:00:00 2001 From: Kristian Bendiksen Date: Fri, 2 Oct 2026 13:13:27 +0200 Subject: [PATCH] Workflows: Add interactive editor for taskmaestro workflows Build, copy and edit taskmaestro workflows in ResInsight and save them as standard workflow.yaml + input.yaml. - Python helper: catalog, load, save and export-registered subcommands, and run --registered for installed workflows - C++ definition model with pure edit operations, port type checks, config field derivation, static validation and undo/redo - Installed folder listing registered workflows (read-only, runnable, can be duplicated) - Graph editor dock with task palette, drag-to-connect, context menus, automatic layout and debounced live validation - New/Duplicate/Save/Save As/Rescan commands; unsaved edits run from a temporary folder and prompt before being discarded - Update the design document and the user README --- ApplicationExeCode/Resources/ResInsight.qrc | 8 + ApplicationExeCode/Resources/codicons/LICENSE | 399 ++++ .../Resources/codicons/README.md | 22 + .../Resources/codicons/calendar.svg | 1 + .../Resources/codicons/check.svg | 1 + .../Resources/codicons/file.svg | 1 + .../Resources/codicons/folder.svg | 1 + .../Resources/codicons/symbol-numeric.svg | 1 + .../Resources/codicons/symbol-string.svg | 1 + .../Application/RiaGuiApplication.cpp | 20 +- .../Application/RiaGuiApplication.h | 1 + .../WorkflowCommands/CMakeLists_files.cmake | 7 + .../RicDuplicateWorkflowAsEditableFeature.cpp | 52 + .../RicDuplicateWorkflowAsEditableFeature.h | 34 + .../RicNewWorkflowFeature.cpp | 52 + .../WorkflowCommands/RicNewWorkflowFeature.h | 34 + .../RicRescanWorkflowsFeature.cpp | 61 + .../RicRescanWorkflowsFeature.h | 34 + .../RicRunWorkflowJobFeature.cpp | 15 + .../RicSaveWorkflowAsFeature.cpp | 52 + .../RicSaveWorkflowAsFeature.h | 34 + .../RicSaveWorkflowFeature.cpp | 52 + .../WorkflowCommands/RicSaveWorkflowFeature.h | 34 + .../RicWorkflowFeatureTools.cpp | 237 +++ .../RicWorkflowFeatureTools.h | 44 + .../RicWorkflowLocationUi.cpp | 151 ++ .../WorkflowCommands/RicWorkflowLocationUi.h | 58 + .../Workflow/CMakeLists_files.cmake | 9 + .../ProjectDataModel/Workflow/RimWorkflow.cpp | 777 +++++++- .../ProjectDataModel/Workflow/RimWorkflow.h | 112 +- .../Workflow/RimWorkflowArrayBinding.cpp | 6 +- .../Workflow/RimWorkflowArrayBinding.h | 4 +- .../Workflow/RimWorkflowBoolBinding.cpp | 5 +- .../Workflow/RimWorkflowBoolBinding.h | 4 +- .../Workflow/RimWorkflowCaseBinding.cpp | 33 +- .../Workflow/RimWorkflowCaseBinding.h | 6 +- .../Workflow/RimWorkflowCollection.cpp | 115 +- .../Workflow/RimWorkflowCollection.h | 19 +- .../Workflow/RimWorkflowDateBinding.cpp | 4 +- .../Workflow/RimWorkflowDateBinding.h | 4 +- .../Workflow/RimWorkflowDefinition.cpp | 147 ++ .../Workflow/RimWorkflowDefinition.h | 151 ++ .../Workflow/RimWorkflowDefinitionTools.cpp | 1205 +++++++++++++ .../Workflow/RimWorkflowDefinitionTools.h | 89 + .../Workflow/RimWorkflowDescribeTools.cpp | 172 +- .../Workflow/RimWorkflowFieldBinding.cpp | 5 +- .../Workflow/RimWorkflowFieldBinding.h | 12 +- .../Workflow/RimWorkflowFilePathBinding.cpp | 4 +- .../Workflow/RimWorkflowFilePathBinding.h | 6 +- .../Workflow/RimWorkflowFloatBinding.cpp | 4 +- .../Workflow/RimWorkflowFloatBinding.h | 4 +- .../Workflow/RimWorkflowHelperProcess.cpp | 131 ++ .../Workflow/RimWorkflowHelperProcess.h | 48 + .../RimWorkflowInstalledCollection.cpp | 29 + .../Workflow/RimWorkflowInstalledCollection.h | 33 + .../Workflow/RimWorkflowIntBinding.cpp | 4 +- .../Workflow/RimWorkflowIntBinding.h | 4 +- .../Workflow/RimWorkflowJob.cpp | 181 +- .../Workflow/RimWorkflowJob.h | 25 +- .../Workflow/RimWorkflowMappingBinding.cpp | 240 +++ .../Workflow/RimWorkflowMappingBinding.h | 57 + .../Workflow/RimWorkflowPortCompatibility.cpp | 716 ++++++++ .../Workflow/RimWorkflowPortCompatibility.h | 87 + .../Workflow/RimWorkflowSchemaTools.cpp | 265 +++ .../Workflow/RimWorkflowSchemaTools.h | 46 + .../Workflow/RimWorkflowStringBinding.cpp | 4 +- .../Workflow/RimWorkflowStringBinding.h | 4 +- .../Workflow/RimWorkflowTaskCatalog.cpp | 192 ++ .../Workflow/RimWorkflowTaskCatalog.h | 61 + .../Workflow/RimWorkflowTaskInput.cpp | 217 ++- .../Workflow/RimWorkflowTaskInput.h | 21 +- .../Workflow/RimWorkflowValidationTools.cpp | 149 ++ .../Workflow/RimWorkflowValidationTools.h | 50 + .../Workflow/RimWorkflowVec3Binding.cpp | 9 +- .../Workflow/RimWorkflowVec3Binding.h | 4 +- .../Workflow/RimWorkflowViewBinding.cpp | 36 +- .../Workflow/RimWorkflowViewBinding.h | 6 +- .../Workflow/RimWorkflowWellPathBinding.cpp | 36 +- .../Workflow/RimWorkflowWellPathBinding.h | 6 +- ApplicationLibCode/UnitTests/CMakeLists.txt | 3 + .../UnitTests/RimWorkflowDefinition-Test.cpp | 609 +++++++ .../RimWorkflowDescribeTools-Test.cpp | 2 + .../UnitTests/RimWorkflowJobSync-Test.cpp | 338 ++++ .../UnitTests/RimWorkflowVec3Binding-Test.cpp | 7 +- .../UnitTests/RiuWorkflowGraphLayout-Test.cpp | 87 + .../RimWorkflowDefinition/catalog.json | 1 + .../RimWorkflowDefinition/completions.json | 1 + .../RimWorkflowDefinition/load_data.json | 1 + .../UserInterface/CMakeLists_files.cmake | 4 + .../UserInterface/RiuMainWindow.cpp | 127 +- .../UserInterface/RiuMainWindow.h | 14 +- .../UserInterface/RiuWorkflowEditorWidget.cpp | 898 +++++++++ .../UserInterface/RiuWorkflowEditorWidget.h | 116 ++ .../UserInterface/RiuWorkflowGraphLayout.cpp | 131 ++ .../UserInterface/RiuWorkflowGraphLayout.h | 44 + .../UserInterface/RiuWorkflowGraphView.cpp | 1599 +++++++++++++++-- .../UserInterface/RiuWorkflowGraphView.h | 121 +- .../UserInterface/RiuWorkflowJobRunner.cpp | 10 + .../UserInterface/RiuWorkflowJobRunner.h | 1 + .../UserInterface/RiuWorkflowTaskPalette.cpp | 194 ++ .../UserInterface/RiuWorkflowTaskPalette.h | 51 + .../RiuWorkflowValidationRunner.cpp | 225 +++ .../RiuWorkflowValidationRunner.h | 65 + GrpcInterface/Python/pyproject.toml | 2 + .../rips/taskmaestro_helper/__init__.py | 10 +- .../rips/taskmaestro_helper/__main__.py | 124 +- .../Python/rips/taskmaestro_helper/catalog.py | 132 ++ .../rips/taskmaestro_helper/definition.py | 117 ++ .../Python/rips/taskmaestro_helper/refs.py | 28 + .../Python/rips/taskmaestro_helper/run.py | 73 +- .../Python/rips/taskmaestro_helper/schema.py | 56 + .../resinsight_completions/input.yaml | 22 + .../resinsight_completions/workflow.yaml | 52 + .../resinsight_load_data/input.yaml | 10 + .../resinsight_load_data/workflow.yaml | 20 + .../tm_helper_pipeline.py | 79 + .../rips/tests/test_taskmaestro_helper.py | 80 +- .../test_taskmaestro_helper_definition.py | 385 ++++ README-experimental-workflow-ui.md | 96 +- docs/workflow-ui-design.md | 415 +++++ 120 files changed, 12637 insertions(+), 678 deletions(-) create mode 100644 ApplicationExeCode/Resources/codicons/LICENSE create mode 100644 ApplicationExeCode/Resources/codicons/README.md create mode 100644 ApplicationExeCode/Resources/codicons/calendar.svg create mode 100644 ApplicationExeCode/Resources/codicons/check.svg create mode 100644 ApplicationExeCode/Resources/codicons/file.svg create mode 100644 ApplicationExeCode/Resources/codicons/folder.svg create mode 100644 ApplicationExeCode/Resources/codicons/symbol-numeric.svg create mode 100644 ApplicationExeCode/Resources/codicons/symbol-string.svg create mode 100644 ApplicationLibCode/Commands/WorkflowCommands/RicDuplicateWorkflowAsEditableFeature.cpp create mode 100644 ApplicationLibCode/Commands/WorkflowCommands/RicDuplicateWorkflowAsEditableFeature.h create mode 100644 ApplicationLibCode/Commands/WorkflowCommands/RicNewWorkflowFeature.cpp create mode 100644 ApplicationLibCode/Commands/WorkflowCommands/RicNewWorkflowFeature.h create mode 100644 ApplicationLibCode/Commands/WorkflowCommands/RicRescanWorkflowsFeature.cpp create mode 100644 ApplicationLibCode/Commands/WorkflowCommands/RicRescanWorkflowsFeature.h create mode 100644 ApplicationLibCode/Commands/WorkflowCommands/RicSaveWorkflowAsFeature.cpp create mode 100644 ApplicationLibCode/Commands/WorkflowCommands/RicSaveWorkflowAsFeature.h create mode 100644 ApplicationLibCode/Commands/WorkflowCommands/RicSaveWorkflowFeature.cpp create mode 100644 ApplicationLibCode/Commands/WorkflowCommands/RicSaveWorkflowFeature.h create mode 100644 ApplicationLibCode/Commands/WorkflowCommands/RicWorkflowFeatureTools.cpp create mode 100644 ApplicationLibCode/Commands/WorkflowCommands/RicWorkflowFeatureTools.h create mode 100644 ApplicationLibCode/Commands/WorkflowCommands/RicWorkflowLocationUi.cpp create mode 100644 ApplicationLibCode/Commands/WorkflowCommands/RicWorkflowLocationUi.h create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDefinition.cpp create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDefinition.h create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDefinitionTools.cpp create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDefinitionTools.h create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowHelperProcess.cpp create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowHelperProcess.h create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowInstalledCollection.cpp create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowInstalledCollection.h create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowMappingBinding.cpp create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowMappingBinding.h create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowPortCompatibility.cpp create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowPortCompatibility.h create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowSchemaTools.cpp create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowSchemaTools.h create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskCatalog.cpp create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskCatalog.h create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowValidationTools.cpp create mode 100644 ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowValidationTools.h create mode 100644 ApplicationLibCode/UnitTests/RimWorkflowDefinition-Test.cpp create mode 100644 ApplicationLibCode/UnitTests/RimWorkflowJobSync-Test.cpp create mode 100644 ApplicationLibCode/UnitTests/RiuWorkflowGraphLayout-Test.cpp create mode 100644 ApplicationLibCode/UnitTests/TestData/RimWorkflowDefinition/catalog.json create mode 100644 ApplicationLibCode/UnitTests/TestData/RimWorkflowDefinition/completions.json create mode 100644 ApplicationLibCode/UnitTests/TestData/RimWorkflowDefinition/load_data.json create mode 100644 ApplicationLibCode/UserInterface/RiuWorkflowEditorWidget.cpp create mode 100644 ApplicationLibCode/UserInterface/RiuWorkflowEditorWidget.h create mode 100644 ApplicationLibCode/UserInterface/RiuWorkflowGraphLayout.cpp create mode 100644 ApplicationLibCode/UserInterface/RiuWorkflowGraphLayout.h create mode 100644 ApplicationLibCode/UserInterface/RiuWorkflowTaskPalette.cpp create mode 100644 ApplicationLibCode/UserInterface/RiuWorkflowTaskPalette.h create mode 100644 ApplicationLibCode/UserInterface/RiuWorkflowValidationRunner.cpp create mode 100644 ApplicationLibCode/UserInterface/RiuWorkflowValidationRunner.h create mode 100644 GrpcInterface/Python/rips/taskmaestro_helper/catalog.py create mode 100644 GrpcInterface/Python/rips/taskmaestro_helper/definition.py create mode 100644 GrpcInterface/Python/rips/taskmaestro_helper/schema.py create mode 100644 GrpcInterface/Python/rips/tests/test_data/taskmaestro_workflows/resinsight_completions/input.yaml create mode 100644 GrpcInterface/Python/rips/tests/test_data/taskmaestro_workflows/resinsight_completions/workflow.yaml create mode 100644 GrpcInterface/Python/rips/tests/test_data/taskmaestro_workflows/resinsight_load_data/input.yaml create mode 100644 GrpcInterface/Python/rips/tests/test_data/taskmaestro_workflows/resinsight_load_data/workflow.yaml create mode 100644 GrpcInterface/Python/rips/tests/test_data/taskmaestro_workflows/tm_helper_pipeline.py create mode 100644 GrpcInterface/Python/rips/tests/test_taskmaestro_helper_definition.py create mode 100644 docs/workflow-ui-design.md 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> RimWorkflowMappingBinding::references() const +{ + std::vector> items; + if ( m_valueType() == "EclipseCase" ) + { + for ( RimEclipseCase* eclipseCase : m_cases.ptrReferencedObjectsByType() ) + { + if ( eclipseCase ) + items.push_back( { eclipseCase->caseUserDescription(), + QJsonObject{ { "__resinsight_ref__", "EclipseCase" }, { "case_id", eclipseCase->caseId() } } } ); + } + } + else if ( m_valueType() == "WellPath" ) + { + for ( RimWellPath* wellPath : m_wellPaths.ptrReferencedObjectsByType() ) + { + if ( wellPath ) + items.push_back( + { wellPath->name(), QJsonObject{ { "__resinsight_ref__", "WellPath" }, { "well_path_name", wellPath->name() } } } ); + } + } + else if ( m_valueType() == "View" ) + { + for ( RimEclipseView* view : m_views.ptrReferencedObjectsByType() ) + { + if ( view ) items.push_back( { view->name(), QJsonObject{ { "__resinsight_ref__", "View" }, { "view_id", view->id() } } } ); + } + } + + QSet used; + for ( auto& [key, reference] : items ) + { + const QString base = key.isEmpty() ? QString( "item" ) : key; + key = base; + for ( int index = 2; used.contains( key ); ++index ) + key = QString( "%1_%2" ).arg( base ).arg( index ); + used.insert( key ); + } + return items; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QJsonValue RimWorkflowMappingBinding::toJsonValue() const +{ + if ( isObjectReference() ) + { + QJsonObject mapping; + for ( const auto& [key, reference] : references() ) + mapping[key] = reference; + return mapping.isEmpty() ? QJsonValue( QJsonValue::Null ) : QJsonValue( mapping ); + } + + if ( !hasValue() ) return QJsonValue::Null; + const QJsonDocument document = QJsonDocument::fromJson( m_text().toUtf8() ); + return document.isObject() ? QJsonValue( document.object() ) : QJsonValue( m_text() ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QString RimWorkflowMappingBinding::displayValue() const +{ + QStringList keys; + if ( isObjectReference() ) + { + for ( const auto& [key, reference] : references() ) + keys.append( key ); + } + else + { + const QJsonValue value = toJsonValue(); + if ( !value.isObject() ) return hasValue() ? "(not a JSON object)" : "(not set)"; + keys = value.toObject().keys(); + } + if ( keys.isEmpty() ) return "(no items)"; + + const QString count = keys.size() == 1 ? QString( "1 item" ) : QString( "%1 items" ).arg( keys.size() ); + QString names = keys.mid( 0, 3 ).join( ", " ); + if ( keys.size() > 3 ) names += ", …"; + return QString( "%1: %2" ).arg( count, names ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QList RimWorkflowMappingBinding::calculateValueOptions( const caf::PdmFieldHandle* fieldNeedingOptions ) +{ + QList options; + if ( fieldNeedingOptions == &m_cases ) RimTools::eclipseCaseOptionItems( &options ); + if ( fieldNeedingOptions == &m_wellPaths ) RimTools::wellPathOptionItems( &options ); + if ( fieldNeedingOptions == &m_views ) + { + for ( RimEclipseCase* eclipseCase : RimEclipseCaseTools::eclipseCases() ) + { + if ( !eclipseCase ) continue; + for ( RimEclipseView* view : eclipseCase->reservoirViews() ) + { + if ( view ) + options.push_back( + caf::PdmOptionItemInfo( QString( "%1 / %2" ).arg( eclipseCase->caseUserDescription(), view->name() ), view ) ); + } + } + } + return options; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RimWorkflowMappingBinding::applySchema( const QJsonObject& fieldSchema ) +{ + m_valueType = fieldSchema.value( "value_resinsight_type" ).toString(); + RimWorkflowFieldBinding::applySchema( fieldSchema ); + + const QJsonObject mapping = fieldSchema.value( "default" ).toObject(); + if ( !isObjectReference() ) + { + if ( fieldSchema.value( "default" ).isObject() ) + m_text = QString::fromUtf8( QJsonDocument( mapping ).toJson( QJsonDocument::Compact ) ); + return; + } + + // Restore the objects of a mapping of references + QSet caseIds; + QSet viewIds; + QSet wellPathNames; + for ( const QJsonValue& value : mapping ) + { + const QJsonObject reference = value.toObject(); + if ( reference.value( "__resinsight_ref__" ).toString() == "EclipseCase" ) + caseIds.insert( reference.value( "case_id" ).toInt( -1 ) ); + if ( reference.value( "__resinsight_ref__" ).toString() == "View" ) viewIds.insert( reference.value( "view_id" ).toInt( -1 ) ); + if ( reference.value( "__resinsight_ref__" ).toString() == "WellPath" ) + wellPathNames.insert( reference.value( "well_path_name" ).toString() ); + } + if ( caseIds.isEmpty() && viewIds.isEmpty() && wellPathNames.isEmpty() ) return; + + std::vector cases; + std::vector views; + for ( RimEclipseCase* eclipseCase : RimEclipseCaseTools::eclipseCases() ) + { + if ( !eclipseCase ) continue; + if ( caseIds.contains( eclipseCase->caseId() ) ) cases.push_back( eclipseCase ); + for ( RimEclipseView* view : eclipseCase->reservoirViews() ) + { + if ( view && viewIds.contains( view->id() ) ) views.push_back( view ); + } + } + std::vector wellPaths; + if ( auto* project = RimProject::current() ) + { + for ( RimWellPath* wellPath : project->allWellPaths() ) + { + if ( wellPath && wellPathNames.contains( wellPath->name() ) ) wellPaths.push_back( wellPath ); + } + } + m_cases.setValue( cases ); + m_views.setValue( views ); + m_wellPaths.setValue( wellPaths ); +} diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowMappingBinding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowMappingBinding.h new file mode 100644 index 0000000000..724d142049 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowMappingBinding.h @@ -0,0 +1,57 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "RimWorkflowFieldBinding.h" + +#include "cafPdmPtrArrayField.h" + +class RimEclipseCase; +class RimEclipseView; +class RimWellPath; + +//================================================================================================== +/// The `dict[str, Value]` a mapped task runs over. ResInsight objects are picked from the project +/// and keyed by name; other values are entered as a JSON object. +//================================================================================================== +class RimWorkflowMappingBinding : public RimWorkflowFieldBinding +{ + CAF_PDM_HEADER_INIT; + +public: + RimWorkflowMappingBinding(); + + void applySchema( const QJsonObject& fieldSchema ) override; + QJsonValue toJsonValue() const override; + bool isObjectReference() const override; + QString displayValue() const override; + + caf::PdmFieldHandle* valueField() override; + +private: + QList calculateValueOptions( const caf::PdmFieldHandle* fieldNeedingOptions ) override; + + std::vector> references() const; + + caf::PdmField m_valueType; + caf::PdmField m_text; + caf::PdmPtrArrayField m_cases; + caf::PdmPtrArrayField m_wellPaths; + caf::PdmPtrArrayField m_views; +}; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowPortCompatibility.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowPortCompatibility.cpp new file mode 100644 index 0000000000..2558658d25 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowPortCompatibility.cpp @@ -0,0 +1,716 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#include "RimWorkflowPortCompatibility.h" + +#include "RimWorkflowDefinition.h" +#include "RimWorkflowSchemaTools.h" + +#include +#include +#include +#include +#include + +namespace +{ +QString shortName( const QString& pythonType ) +{ + return pythonType.mid( pythonType.lastIndexOf( '.' ) + 1 ); +} + +QStringList pythonTypes( const QJsonObject& schema ) +{ + QStringList types; + for ( const char* key : { "x-taskmaestro-python-type", "x-ri-python-type" } ) + { + const QString type = schema.value( key ).toString(); + if ( !type.isEmpty() ) types.append( type ); + } + for ( const QJsonValue& base : schema.value( "x-ri-python-bases" ).toArray() ) + types.append( base.toString() ); + return types; +} + +QJsonArray enumValues( const QJsonObject& schema ) +{ + if ( schema.contains( "const" ) ) return QJsonArray{ schema.value( "const" ) }; + return schema.value( "enum" ).toArray(); +} + +bool isHiddenObjectValue( const QString& name, const QJsonObject& property, const QJsonObject& rootSchema ) +{ + // ObjectModel wraps its runtime object in an opaque `value` field that is not routable on its own + return name == "value" && RimWorkflowSchemaTools::isOpaque( property, rootSchema ); +} + +bool isModelCompatible( const QJsonObject& produced, const QJsonObject& expected ) +{ + const QString expectedType = expected.value( "x-ri-python-type" ).toString(); + if ( !expectedType.isEmpty() && produced.contains( "x-ri-python-type" ) ) return pythonTypes( produced ).contains( expectedType ); + + if ( expected.contains( "title" ) && produced.contains( "title" ) && expected.contains( "properties" ) ) + return expected.value( "title" ) == produced.value( "title" ); + return true; +} + +bool isScalarCompatible( const QJsonObject& produced, const QJsonObject& expected ) +{ + const QString producedType = produced.value( "type" ).toString(); + const QString expectedType = expected.value( "type" ).toString(); + + // bool is a subclass of int in Python, but int is not a subclass of float + if ( producedType != expectedType && !( producedType == "boolean" && expectedType == "integer" ) ) return false; + if ( produced.value( "format" ).toString() != expected.value( "format" ).toString() ) return false; + + const QJsonArray expectedEnum = enumValues( expected ); + if ( expectedEnum.isEmpty() ) return true; + const QJsonArray producedEnum = enumValues( produced ); + if ( producedEnum.isEmpty() ) return false; + for ( const QJsonValue& value : producedEnum ) + { + if ( !expectedEnum.contains( value ) ) return false; + } + return true; +} + +std::optional findPort( const std::vector& ports, const QString& name ) +{ + for ( const auto& port : ports ) + { + if ( port.name == name ) return port; + } + return std::nullopt; +} + +bool hasRequiredConfigurableField( const QJsonObject& inputSchema, const QStringList& covered ) +{ + const QJsonObject properties = inputSchema.value( "properties" ).toObject(); + for ( const QString& field : RimWorkflowSchemaTools::requiredFields( inputSchema ) ) + { + if ( covered.contains( field ) ) continue; + if ( RimWorkflowSchemaTools::isConfigurable( properties.value( field ).toObject(), inputSchema ) ) return true; + } + return false; +} + +std::expected + checkWholeToWhole( const QJsonObject& upstreamType, const QJsonObject& downstreamType, const QString& from, const QString& to ) +{ + const QJsonObject outputSchema = upstreamType.value( "output_schema" ).toObject(); + const QJsonObject inputSchema = downstreamType.value( "input_schema" ).toObject(); + if ( RimWorkflowPortCompatibility::isCompatible( outputSchema, outputSchema, inputSchema, inputSchema ) ) return {}; + + const QString mismatch = QString( "'%1' outputs %2, but '%3' expects %4" ) + .arg( from, RimWorkflowPortCompatibility::typeName( outputSchema, outputSchema ) ) + .arg( to, RimWorkflowPortCompatibility::typeName( inputSchema, inputSchema ) ); + + // taskmaestro merges the upstream fields with configured values when the task has config fields + const QStringList shared = RimWorkflowPortCompatibility::coveredByWholeOutput( upstreamType, downstreamType ); + if ( shared.isEmpty() || !hasRequiredConfigurableField( inputSchema, shared ) ) return std::unexpected( mismatch ); + + const QJsonObject outputProperties = outputSchema.value( "properties" ).toObject(); + const QJsonObject inputProperties = inputSchema.value( "properties" ).toObject(); + for ( const QString& field : shared ) + { + if ( !RimWorkflowPortCompatibility::isCompatible( outputProperties.value( field ).toObject(), + outputSchema, + inputProperties.value( field ).toObject(), + inputSchema ) ) + { + return std::unexpected( QString( "Type mismatch for field '%1': %2" ).arg( field, mismatch ) ); + } + } + return {}; +} +//-------------------------------------------------------------------------------------------------- +/// Icon of the ResInsight object a schema holds, or an empty string. The rips class is found in the +/// Python class bases: directly for opaque rips objects, or as `ObjectModel[Class]` for wrappers. +/// The icons are the ones the matching ResInsight project classes use. +//-------------------------------------------------------------------------------------------------- +QString objectIconResource( const QJsonObject& schema, const QJsonObject& rootSchema ) +{ + const QJsonObject resolved = RimWorkflowSchemaTools::resolveReferences( schema, rootSchema ); + for ( const char* unionKey : { "anyOf", "oneOf" } ) + { + for ( const QJsonValue& option : resolved.value( unionKey ).toArray() ) + { + const QString icon = objectIconResource( option.toObject(), rootSchema ); + if ( !icon.isEmpty() ) return icon; + } + } + if ( resolved.value( "type" ).toString() == "array" ) return objectIconResource( resolved.value( "items" ).toObject(), rootSchema ); + if ( resolved.value( "type" ).toString() == "object" && resolved.value( "additionalProperties" ).isObject() && + !resolved.contains( "properties" ) ) + return objectIconResource( resolved.value( "additionalProperties" ).toObject(), rootSchema ); + + static const QMap icons = { { "Instance", ":/AppLogo48x48.png" }, + { "Case", ":/Case48x48.png" }, + { "Reservoir", ":/Case48x48.png" }, + { "EclipseCase", ":/Case48x48.png" }, + { "View", ":/3DView16x16.png" }, + { "EclipseView", ":/3DView16x16.png" }, + { "WellPath", ":/Well.svg" }, + { "Surface", ":/ReservoirSurface16x16.png" } }; + + QJsonArray bases = resolved.value( "x-ri-python-bases" ).toArray(); + if ( bases.isEmpty() && resolved.contains( "x-ri-python-type" ) ) bases.append( resolved.value( "x-ri-python-type" ) ); + + static const QRegularExpression objectModel( R"(ObjectModel\[(\w+)\]$)" ); + for ( const QJsonValue& value : bases ) + { + const QString base = value.toString(); + QString className; + if ( const auto match = objectModel.match( base ); match.hasMatch() ) + className = match.captured( 1 ); + else if ( base.startsWith( "rips." ) ) + className = base.section( '.', -1 ); + if ( icons.contains( className ) ) return icons.value( className ); + } + return {}; +} + +//-------------------------------------------------------------------------------------------------- +/// Icon of a plain value (string, number, bool, date, path), or an empty string. The icons are Codicons, +/// see Resources/codicons/README.md. +//-------------------------------------------------------------------------------------------------- +QString valueIconResource( const QJsonObject& schema, const QJsonObject& rootSchema ) +{ + const QJsonObject resolved = RimWorkflowSchemaTools::resolveReferences( schema, rootSchema ); + for ( const char* unionKey : { "anyOf", "oneOf" } ) + { + for ( const QJsonValue& option : resolved.value( unionKey ).toArray() ) + { + const QString icon = valueIconResource( option.toObject(), rootSchema ); + if ( !icon.isEmpty() ) return icon; + } + } + + const QString type = resolved.value( "type" ).toString(); + if ( type == "array" ) return valueIconResource( resolved.value( "items" ).toObject(), rootSchema ); + if ( type == "object" && resolved.value( "additionalProperties" ).isObject() && !resolved.contains( "properties" ) ) + return valueIconResource( resolved.value( "additionalProperties" ).toObject(), rootSchema ); + + if ( type == "boolean" ) return ":/codicons/check.svg"; + if ( type == "integer" || type == "number" ) return ":/codicons/symbol-numeric.svg"; + if ( type != "string" ) return {}; + + const QString format = resolved.value( "format" ).toString(); + if ( format == "date" || format == "date-time" ) return ":/codicons/calendar.svg"; + if ( format == "directory-path" ) return ":/codicons/folder.svg"; + if ( format == "path" || format == "file-path" ) return ":/codicons/file.svg"; + return ":/codicons/symbol-string.svg"; +} + +} // namespace + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RimWorkflowPort::isWhole() const +{ + return name.isEmpty(); +} + +//-------------------------------------------------------------------------------------------------- +/// The whole input model first, then its fields +//-------------------------------------------------------------------------------------------------- +std::vector RimWorkflowPortCompatibility::inputPorts( const QJsonObject& taskType ) +{ + const QJsonObject inputSchema = taskType.value( "input_schema" ).toObject(); + + std::vector ports; + ports.push_back( { .name = "", + .schema = inputSchema, + .rootSchema = inputSchema, + .description = inputSchema.value( "description" ).toString(), + .typeName = typeName( inputSchema, inputSchema ), + .iconResource = iconResource( inputSchema, inputSchema ), + .required = true } ); + + const QStringList required = RimWorkflowSchemaTools::requiredFields( inputSchema ); + const QJsonObject properties = inputSchema.value( "properties" ).toObject(); + for ( const QString& name : RimWorkflowSchemaTools::propertyNames( inputSchema ) ) + { + const QJsonObject property = properties.value( name ).toObject(); + if ( isHiddenObjectValue( name, property, inputSchema ) ) continue; + ports.push_back( { .name = name, + .schema = property, + .rootSchema = inputSchema, + .description = property.value( "description" ).toString(), + .typeName = typeName( property, inputSchema ), + .iconResource = iconResource( property, inputSchema ), + .required = required.contains( name ), + .configurable = RimWorkflowSchemaTools::isConfigurable( property, inputSchema ) } ); + } + return ports; +} + +//-------------------------------------------------------------------------------------------------- +/// The whole output model first, then its fields +//-------------------------------------------------------------------------------------------------- +std::vector RimWorkflowPortCompatibility::outputPorts( const QJsonObject& taskType ) +{ + const QJsonObject outputSchema = taskType.value( "output_schema" ).toObject(); + + std::vector ports; + ports.push_back( { .name = "", + .schema = outputSchema, + .rootSchema = outputSchema, + .description = outputSchema.value( "description" ).toString(), + .typeName = typeName( outputSchema, outputSchema ), + .iconResource = iconResource( outputSchema, outputSchema ) } ); + + const QJsonObject properties = outputSchema.value( "properties" ).toObject(); + for ( const QString& name : RimWorkflowSchemaTools::propertyNames( outputSchema ) ) + { + const QJsonObject property = properties.value( name ).toObject(); + if ( isHiddenObjectValue( name, property, outputSchema ) ) continue; + ports.push_back( { .name = name, + .schema = property, + .rootSchema = outputSchema, + .description = property.value( "description" ).toString(), + .typeName = typeName( property, outputSchema ), + .iconResource = iconResource( property, outputSchema ) } ); + } + return ports; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +std::optional RimWorkflowPortCompatibility::inputPort( const QJsonObject& taskType, const QString& name ) +{ + return findPort( inputPorts( taskType ), name ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +std::optional RimWorkflowPortCompatibility::outputPort( const QJsonObject& taskType, const QString& name ) +{ + return findPort( outputPorts( taskType ), name ); +} + +//-------------------------------------------------------------------------------------------------- +/// A schema without any type information accepts and produces anything +//-------------------------------------------------------------------------------------------------- +bool RimWorkflowPortCompatibility::isAny( const QJsonObject& schema, const QJsonObject& rootSchema ) +{ + const QJsonObject resolved = RimWorkflowSchemaTools::resolveReferences( schema, rootSchema ); + for ( const char* key : { "type", "anyOf", "oneOf", "properties", "enum", "const", "x-taskmaestro-opaque", "$ref" } ) + { + if ( resolved.contains( key ) ) return false; + } + return true; +} + +//-------------------------------------------------------------------------------------------------- +/// Whether every value of the produced schema is accepted by the expected schema. Models and +/// runtime objects compare their Python class and its bases, like `issubclass`. +//-------------------------------------------------------------------------------------------------- +bool RimWorkflowPortCompatibility::isCompatible( const QJsonObject& produced, + const QJsonObject& producedRoot, + const QJsonObject& expected, + const QJsonObject& expectedRoot ) +{ + if ( isAny( produced, producedRoot ) || isAny( expected, expectedRoot ) ) return true; + + const QJsonObject p = RimWorkflowSchemaTools::resolveReferences( produced, producedRoot ); + const QJsonObject e = RimWorkflowSchemaTools::resolveReferences( expected, expectedRoot ); + + // Every produced option must be accepted; an expected union needs one accepting option + for ( const char* unionKey : { "anyOf", "oneOf" } ) + { + if ( !p.value( unionKey ).isArray() ) continue; + for ( const QJsonValue& option : p.value( unionKey ).toArray() ) + { + if ( !isCompatible( option.toObject(), producedRoot, e, expectedRoot ) ) return false; + } + return true; + } + for ( const char* unionKey : { "anyOf", "oneOf" } ) + { + if ( !e.value( unionKey ).isArray() ) continue; + for ( const QJsonValue& option : e.value( unionKey ).toArray() ) + { + if ( isCompatible( p, producedRoot, option.toObject(), expectedRoot ) ) return true; + } + return false; + } + + const bool producedOpaque = p.value( "x-taskmaestro-opaque" ).toBool( false ); + if ( e.value( "x-taskmaestro-opaque" ).toBool( false ) ) + { + return producedOpaque && pythonTypes( p ).contains( e.value( "x-taskmaestro-python-type" ).toString() ); + } + if ( producedOpaque ) return false; + + const QString producedType = p.value( "type" ).toString(); + const QString expectedType = e.value( "type" ).toString(); + if ( producedType == "object" || expectedType == "object" ) + { + if ( producedType != expectedType ) return false; + if ( e.value( "additionalProperties" ).isObject() && !e.contains( "properties" ) ) + { + return isCompatible( p.value( "additionalProperties" ).toObject(), + producedRoot, + e.value( "additionalProperties" ).toObject(), + expectedRoot ); + } + return isModelCompatible( p, e ); + } + + if ( producedType == "array" || expectedType == "array" ) + { + if ( producedType != expectedType ) return false; + return isCompatible( p.value( "items" ).toObject(), producedRoot, e.value( "items" ).toObject(), expectedRoot ); + } + + return isScalarCompatible( p, e ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RimWorkflowPortCompatibility::isCompatible( const RimWorkflowPort& produced, const RimWorkflowPort& expected ) +{ + return isCompatible( produced.schema, produced.rootSchema, expected.schema, expected.rootSchema ); +} + +//-------------------------------------------------------------------------------------------------- +/// Short readable type name used in tooltips and error messages +//-------------------------------------------------------------------------------------------------- +QString RimWorkflowPortCompatibility::typeName( const QJsonObject& schema, const QJsonObject& rootSchema ) +{ + if ( isAny( schema, rootSchema ) ) return "Any"; + + const QJsonObject resolved = RimWorkflowSchemaTools::resolveReferences( schema, rootSchema ); + for ( const char* unionKey : { "anyOf", "oneOf" } ) + { + if ( !resolved.value( unionKey ).isArray() ) continue; + QStringList names; + for ( const QJsonValue& option : resolved.value( unionKey ).toArray() ) + names.append( typeName( option.toObject(), rootSchema ) ); + return names.join( " | " ); + } + + if ( resolved.value( "x-taskmaestro-opaque" ).toBool( false ) ) + return shortName( resolved.value( "x-taskmaestro-python-type" ).toString( "object" ) ); + if ( resolved.contains( "x-ri-python-type" ) ) return shortName( resolved.value( "x-ri-python-type" ).toString() ); + + const QString type = resolved.value( "type" ).toString(); + if ( type == "object" && resolved.value( "additionalProperties" ).isObject() && !resolved.contains( "properties" ) ) + return QString( "dict[str, %1]" ).arg( typeName( resolved.value( "additionalProperties" ).toObject(), rootSchema ) ); + if ( type == "object" ) return resolved.value( "title" ).toString( "object" ); + if ( type == "array" ) return QString( "list[%1]" ).arg( typeName( resolved.value( "items" ).toObject(), rootSchema ) ); + + static const QMap pythonNames = { { "string", "str" }, + { "integer", "int" }, + { "number", "float" }, + { "boolean", "bool" }, + { "null", "None" } }; + const QString format = resolved.value( "format" ).toString(); + if ( format == "date" ) return "date"; + if ( format == "date-time" ) return "datetime"; + if ( format == "path" || format == "file-path" || format == "directory-path" ) return "Path"; + return pythonNames.value( type, type ); +} + +//-------------------------------------------------------------------------------------------------- +/// Icon of a port: the ResInsight object it holds, otherwise the kind of value +//-------------------------------------------------------------------------------------------------- +QString RimWorkflowPortCompatibility::iconResource( const QJsonObject& schema, const QJsonObject& rootSchema ) +{ + const QString objectIcon = objectIconResource( schema, rootSchema ); + return objectIcon.isEmpty() ? valueIconResource( schema, rootSchema ) : objectIcon; +} + +//-------------------------------------------------------------------------------------------------- +/// Port name -> icon resource, for the ports that have an icon +//-------------------------------------------------------------------------------------------------- +QJsonObject RimWorkflowPortCompatibility::portIcons( const std::vector& ports ) +{ + QJsonObject icons; + for ( const auto& port : ports ) + { + if ( !port.iconResource.isEmpty() ) icons[port.name] = port.iconResource; + } + return icons; +} + +//-------------------------------------------------------------------------------------------------- +/// Port name -> type name, with an empty name for the whole model. Used to label ports in the graph. +//-------------------------------------------------------------------------------------------------- +QJsonObject RimWorkflowPortCompatibility::portTypes( const std::vector& ports ) +{ + QJsonObject types; + for ( const auto& port : ports ) + types[port.name] = port.typeName; + return types; +} + +//-------------------------------------------------------------------------------------------------- +/// Input fields of the downstream task supplied when it takes the whole upstream output +//-------------------------------------------------------------------------------------------------- +QStringList RimWorkflowPortCompatibility::coveredByWholeOutput( const QJsonObject& upstreamTaskType, const QJsonObject& downstreamTaskType ) +{ + const QJsonObject outputSchema = upstreamTaskType.value( "output_schema" ).toObject(); + const QJsonObject inputSchema = downstreamTaskType.value( "input_schema" ).toObject(); + const QStringList inputFields = RimWorkflowSchemaTools::propertyNames( inputSchema ); + if ( isCompatible( outputSchema, outputSchema, inputSchema, inputSchema ) ) return inputFields; + + const QStringList outputFields = RimWorkflowSchemaTools::propertyNames( outputSchema ); + QStringList covered; + for ( const QString& field : inputFields ) + { + if ( outputFields.contains( field ) ) covered.append( field ); + } + return covered; +} + +//-------------------------------------------------------------------------------------------------- +/// Whether adding the connection from -> to closes a cycle +//-------------------------------------------------------------------------------------------------- +bool RimWorkflowPortCompatibility::wouldCreateCycle( const RimWorkflowDefinition& definition, const QString& from, const QString& to ) +{ + if ( from == to ) return true; + + QStringList pending{ to }; + QSet visited; + while ( !pending.isEmpty() ) + { + const QString current = pending.takeLast(); + if ( current == from ) return true; + if ( visited.contains( current ) ) continue; + visited.insert( current ); + for ( const auto& edge : definition.edges ) + { + if ( edge.from == current ) pending.append( edge.to ); + } + } + return false; +} + +//-------------------------------------------------------------------------------------------------- +/// A mapped task runs once per item: the key and value fields are filled by the map and are not +/// inputs, and the output is `dict[str, Output]` +//-------------------------------------------------------------------------------------------------- +QJsonObject RimWorkflowPortCompatibility::mappedTaskType( const QJsonObject& taskType, const RimWorkflowTaskMap& map ) +{ + QJsonObject inputSchema = taskType.value( "input_schema" ).toObject(); + QJsonObject properties = inputSchema.value( "properties" ).toObject(); + properties.remove( map.keyAs ); + properties.remove( map.valueAs ); + inputSchema["properties"] = properties; + + QJsonArray required; + for ( const QJsonValue& field : inputSchema.value( "required" ).toArray() ) + { + if ( field.toString() != map.keyAs && field.toString() != map.valueAs ) required.append( field ); + } + inputSchema["required"] = required; + + QJsonObject result = taskType; + result["input_schema"] = inputSchema; + result["output_schema"] = mappedOutputSchema( taskType.value( "output_schema" ).toObject() ); + return result; +} + +//-------------------------------------------------------------------------------------------------- +/// `MappedOutput[Output]`, a `dict[str, Output]`. The output model moves into `$defs`, so its own +/// references still resolve against the new root. +//-------------------------------------------------------------------------------------------------- +QJsonObject RimWorkflowPortCompatibility::mappedOutputSchema( const QJsonObject& outputSchema ) +{ + QJsonObject definitions = outputSchema.value( "$defs" ).toObject(); + QJsonObject item = outputSchema; + item.remove( "$defs" ); + + QString itemKey = "MappedItem"; + while ( definitions.contains( itemKey ) ) + itemKey += "_"; + definitions[itemKey] = item; + + return QJsonObject{ { "type", "object" }, + { "title", QString( "dict[str, %1]" ).arg( typeName( outputSchema, outputSchema ) ) }, + { "additionalProperties", QJsonObject{ { "$ref", "#/$defs/" + itemKey } } }, + { "$defs", definitions }, + { "x-ri-mapped-output", true } }; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +std::optional> + RimWorkflowPortCompatibility::collectTarget( const RimWorkflowPort& expected ) +{ + if ( expected.isWhole() ) return std::nullopt; + + const QJsonObject resolved = RimWorkflowSchemaTools::resolveReferences( expected.schema, expected.rootSchema ); + QJsonArray options; + for ( const char* unionKey : { "anyOf", "oneOf" } ) + { + for ( const QJsonValue& option : resolved.value( unionKey ).toArray() ) + options.append( option ); + } + if ( options.isEmpty() ) options.append( resolved ); + + for ( const QJsonValue& value : options ) + { + const QJsonObject option = RimWorkflowSchemaTools::resolveReferences( value.toObject(), expected.rootSchema ); + const QString type = option.value( "type" ).toString(); + if ( type == "array" && option.value( "items" ).isObject() ) + return std::make_pair( RimWorkflowDefinitionEdge::Collect::List, option.value( "items" ).toObject() ); + if ( type == "object" && option.value( "additionalProperties" ).isObject() && !option.contains( "properties" ) ) + return std::make_pair( RimWorkflowDefinitionEdge::Collect::Dict, option.value( "additionalProperties" ).toObject() ); + } + return std::nullopt; +} + +//-------------------------------------------------------------------------------------------------- +/// Checks a new connection and returns the edge to add +//-------------------------------------------------------------------------------------------------- +std::expected RimWorkflowPortCompatibility::resolveConnection( const RimWorkflowDefinition& definition, + const QString& from, + const QString& output, + const QString& to, + const QString& input ) +{ + using Collect = RimWorkflowDefinitionEdge::Collect; + + if ( from == to ) return std::unexpected( "A task cannot be connected to itself" ); + if ( !definition.findNode( from ) ) return std::unexpected( QString( "Unknown task '%1'" ).arg( from ) ); + const auto* downstream = definition.findNode( to ); + if ( !downstream ) return std::unexpected( QString( "Unknown task '%1'" ).arg( to ) ); + + const QJsonObject upstreamType = definition.taskTypeForNode( from ); + const QJsonObject downstreamType = definition.taskTypeForNode( to ); + if ( upstreamType.isEmpty() ) return std::unexpected( QString( "The task type of '%1' is not installed" ).arg( from ) ); + if ( downstreamType.isEmpty() ) return std::unexpected( QString( "The task type of '%1' is not installed" ).arg( to ) ); + + const auto producedPort = outputPort( upstreamType, output ); + if ( !producedPort ) return std::unexpected( QString( "'%1' has no output '%2'" ).arg( from, output ) ); + const auto expectedPort = inputPort( downstreamType, input ); + if ( !expectedPort ) return std::unexpected( QString( "'%1' has no input '%2'" ).arg( to, input ) ); + if ( downstream->map && input.isEmpty() ) + return std::unexpected( QString( "'%1' is mapped and takes its inputs field by field" ).arg( to ) ); + + RimWorkflowDefinitionEdge edge{ .from = from, .output = output, .to = to, .input = input }; + if ( !input.isEmpty() && !isCompatible( *producedPort, *expectedPort ) ) + { + const auto target = collectTarget( *expectedPort ); + if ( target && isCompatible( producedPort->schema, producedPort->rootSchema, target->second, expectedPort->rootSchema ) ) + { + edge.collect = target->first; + } + else + { + const QString source = output.isEmpty() ? from : from + "." + output; + return std::unexpected( + QString( "%1 is %2, but %3.%4 expects %5" ).arg( source, producedPort->typeName, to, input, expectedPort->typeName ) ); + } + } + + // Edges into the same input are replaced by the new connection, except other collected members + RimWorkflowDefinition remaining = definition; + std::erase_if( remaining.edges, + [&]( const RimWorkflowDefinitionEdge& existing ) + { return existing.to == to && existing.input == input && ( !edge.isCollected() || existing.collect != edge.collect ); } ); + if ( wouldCreateCycle( remaining, from, to ) ) return std::unexpected( "The connection would create a cycle" ); + + QStringList keys; + for ( const auto& existing : remaining.incomingEdges( to ) ) + { + if ( input.isEmpty() && !existing.isWholeInput() ) + return std::unexpected( QString( "'%1' already has individual inputs connected and cannot also take a whole input" ).arg( to ) ); + if ( !input.isEmpty() && existing.isWholeInput() ) + return std::unexpected( QString( "'%1' takes its whole input from '%2'; disconnect it first" ).arg( to, existing.from ) ); + if ( existing.input != input ) continue; + if ( existing.sameConnection( edge ) ) + return std::unexpected( QString( "%1 is already collected into %2.%3" ).arg( from, to, input ) ); + keys.append( existing.key ); + } + + if ( input.isEmpty() && output.isEmpty() ) + { + if ( auto check = checkWholeToWhole( upstreamType, downstreamType, from, to ); !check ) return std::unexpected( check.error() ); + return edge; + } + if ( input.isEmpty() && !isCompatible( *producedPort, *expectedPort ) ) + { + return std::unexpected( + QString( "%1.%2 is %3, but %4 expects %5" ).arg( from, output, producedPort->typeName, to, expectedPort->typeName ) ); + } + + if ( edge.collect == Collect::Dict ) + { + // The upstream task name is the default key + edge.key = output.isEmpty() ? from : from + "_" + output; + for ( int index = 2; keys.contains( edge.key ); ++index ) + edge.key = QString( "%1_%2" ).arg( output.isEmpty() ? from : from + "_" + output ).arg( index ); + } + return edge; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +std::expected RimWorkflowPortCompatibility::canConnect( const RimWorkflowDefinition& definition, + const QString& from, + const QString& output, + const QString& to, + const QString& input ) +{ + auto edge = resolveConnection( definition, from, output, to, input ); + if ( !edge ) return std::unexpected( edge.error() ); + return {}; +} + +//-------------------------------------------------------------------------------------------------- +/// Type check of one member of a collected input +//-------------------------------------------------------------------------------------------------- +std::expected RimWorkflowPortCompatibility::checkCollectMember( const RimWorkflowDefinition& definition, + const RimWorkflowDefinitionEdge& edge ) +{ + const QJsonObject upstreamType = definition.taskTypeForNode( edge.from ); + const QJsonObject downstreamType = definition.taskTypeForNode( edge.to ); + if ( upstreamType.isEmpty() || downstreamType.isEmpty() ) return {}; + + const auto producedPort = outputPort( upstreamType, edge.output ); + if ( !producedPort ) return std::unexpected( QString( "'%1' has no output '%2'" ).arg( edge.from, edge.output ) ); + const auto expectedPort = inputPort( downstreamType, edge.input ); + if ( !expectedPort || expectedPort->isWhole() ) + return std::unexpected( QString( "'%1' has no input '%2'" ).arg( edge.to, edge.input ) ); + + const auto target = collectTarget( *expectedPort ); + const bool isList = edge.collect == RimWorkflowDefinitionEdge::Collect::List; + if ( !target || target->first != edge.collect ) + { + return std::unexpected( QString( "Input '%1' is %2 and cannot collect %3" ) + .arg( edge.input, expectedPort->typeName, isList ? "a list of members" : "keyed members" ) ); + } + if ( !isCompatible( producedPort->schema, producedPort->rootSchema, target->second, expectedPort->rootSchema ) ) + { + const QString source = edge.output.isEmpty() ? edge.from : edge.from + "." + edge.output; + return std::unexpected( + QString( "%1 is %2, but the members of %3.%4 must be %5" ) + .arg( source, producedPort->typeName, edge.to, edge.input, typeName( target->second, expectedPort->rootSchema ) ) ); + } + return {}; +} diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowPortCompatibility.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowPortCompatibility.h new file mode 100644 index 0000000000..a86a068df8 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowPortCompatibility.h @@ -0,0 +1,87 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "RimWorkflowDefinition.h" + +#include +#include + +#include +#include +#include + +//================================================================================================== +/// A connectable input or output of a task. An empty name is the whole input or output model. +//================================================================================================== +struct RimWorkflowPort +{ + QString name{}; + QJsonObject schema{}; + QJsonObject rootSchema{}; + QString description{}; + QString typeName{}; + QString iconResource{}; + bool required = true; + bool configurable = false; + + bool isWhole() const; +}; + +//================================================================================================== +/// Static type checks for workflow connections. These mirror taskmaestro's `_is_type_compatible` +/// on the JSON schemas; `taskmaestro workflow describe` has the final say. +//================================================================================================== +namespace RimWorkflowPortCompatibility +{ +std::vector inputPorts( const QJsonObject& taskType ); +std::vector outputPorts( const QJsonObject& taskType ); +std::optional inputPort( const QJsonObject& taskType, const QString& name ); +std::optional outputPort( const QJsonObject& taskType, const QString& name ); + +bool isCompatible( const QJsonObject& produced, const QJsonObject& producedRoot, const QJsonObject& expected, const QJsonObject& expectedRoot ); +bool isCompatible( const RimWorkflowPort& produced, const RimWorkflowPort& expected ); +bool isAny( const QJsonObject& schema, const QJsonObject& rootSchema ); +QString typeName( const QJsonObject& schema, const QJsonObject& rootSchema ); +QString iconResource( const QJsonObject& schema, const QJsonObject& rootSchema ); +QJsonObject portTypes( const std::vector& ports ); +QJsonObject portIcons( const std::vector& ports ); + +QStringList coveredByWholeOutput( const QJsonObject& upstreamTaskType, const QJsonObject& downstreamTaskType ); +bool wouldCreateCycle( const RimWorkflowDefinition& definition, const QString& from, const QString& to ); + +// Mapped tasks +QJsonObject mappedTaskType( const QJsonObject& taskType, const RimWorkflowTaskMap& map ); +QJsonObject mappedOutputSchema( const QJsonObject& outputSchema ); + +// Collected inputs: the kind and element schema of a `list[T]` or `dict[str, T]` input +std::optional> collectTarget( const RimWorkflowPort& expected ); + +// The edge to add for a new connection, collecting into a list or dict input when the upstream +// value is an element of it. An existing connection to the same input is replaced, except for +// other members of the same collection. +std::expected resolveConnection( const RimWorkflowDefinition& definition, + const QString& from, + const QString& output, + const QString& to, + const QString& input ); +std::expected + canConnect( const RimWorkflowDefinition& definition, const QString& from, const QString& output, const QString& to, const QString& input ); +std::expected checkCollectMember( const RimWorkflowDefinition& definition, const RimWorkflowDefinitionEdge& edge ); +} // namespace RimWorkflowPortCompatibility diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowSchemaTools.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowSchemaTools.cpp new file mode 100644 index 0000000000..bed16d3df0 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowSchemaTools.cpp @@ -0,0 +1,265 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#include "RimWorkflowSchemaTools.h" + +#include +#include +#include + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QJsonObject RimWorkflowSchemaTools::resolveRef( const QString& ref, const QJsonObject& rootSchema ) +{ + const QString prefix = "#/$defs/"; + if ( !ref.startsWith( prefix ) ) return {}; + return rootSchema.value( "$defs" ).toObject().value( ref.mid( prefix.size() ) ).toObject(); +} + +//-------------------------------------------------------------------------------------------------- +/// Follow $ref and unwrap single-item allOf, keeping the sibling keywords of a $ref (description, default) +//-------------------------------------------------------------------------------------------------- +QJsonObject RimWorkflowSchemaTools::resolveReferences( QJsonObject schema, const QJsonObject& rootSchema ) +{ + constexpr int maxDepth = 16; + for ( int depth = 0; depth < maxDepth; ++depth ) + { + if ( schema.contains( "$ref" ) ) + { + QJsonObject target = resolveRef( schema.value( "$ref" ).toString(), rootSchema ); + schema.remove( "$ref" ); + for ( auto it = schema.begin(); it != schema.end(); ++it ) + { + if ( !target.contains( it.key() ) ) target.insert( it.key(), it.value() ); + } + schema = target; + continue; + } + + const QJsonArray allOf = schema.value( "allOf" ).toArray(); + if ( allOf.size() == 1 ) + { + schema = allOf.first().toObject(); + continue; + } + break; + } + return schema; +} + +//-------------------------------------------------------------------------------------------------- +/// Follow $ref and unwrap single-item allOf and Optional (anyOf with null) to the underlying type +//-------------------------------------------------------------------------------------------------- +QJsonObject RimWorkflowSchemaTools::underlyingSchema( QJsonObject schema, const QJsonObject& rootSchema ) +{ + constexpr int maxDepth = 16; + for ( int depth = 0; depth < maxDepth; ++depth ) + { + if ( schema.contains( "$ref" ) ) + { + schema = resolveRef( schema.value( "$ref" ).toString(), rootSchema ); + continue; + } + + const QJsonArray allOf = schema.value( "allOf" ).toArray(); + if ( allOf.size() == 1 ) + { + schema = allOf.first().toObject(); + continue; + } + + if ( schema.value( "anyOf" ).isArray() ) + { + QJsonArray nonNull; + for ( const QJsonValue& arm : schema.value( "anyOf" ).toArray() ) + { + if ( arm.toObject().value( "type" ).toString() != "null" ) nonNull.append( arm ); + } + if ( nonNull.size() == 1 ) + { + schema = nonNull.first().toObject(); + continue; + } + } + break; + } + return schema; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RimWorkflowSchemaTools::isOpaque( const QJsonObject& schema, const QJsonObject& rootSchema ) +{ + return underlyingSchema( schema, rootSchema ).value( "x-taskmaestro-opaque" ).toBool( false ); +} + +//-------------------------------------------------------------------------------------------------- +/// Python type of a runtime-only object, either bare or wrapped in an ObjectModel `value` field +//-------------------------------------------------------------------------------------------------- +QString RimWorkflowSchemaTools::objectPythonType( const QJsonObject& typeSchema, const QJsonObject& rootSchema ) +{ + if ( typeSchema.contains( "x-taskmaestro-python-type" ) ) return typeSchema.value( "x-taskmaestro-python-type" ).toString(); + + const QJsonObject valueSchema = typeSchema.value( "properties" ).toObject().value( "value" ).toObject(); + if ( valueSchema.isEmpty() ) return {}; + return underlyingSchema( valueSchema, rootSchema ).value( "x-taskmaestro-python-type" ).toString(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QString RimWorkflowSchemaTools::jsonType( const QJsonObject& typeSchema ) +{ + const QString type = typeSchema.value( "type" ).toString(); + if ( type == "boolean" || type == "integer" || type == "number" || type == "array" ) return type; + return "string"; +} + +//-------------------------------------------------------------------------------------------------- +/// taskmaestro_resinsight.models.Vec3 is a plain pydantic model (x, y, z numbers), so it has no +/// x-taskmaestro-python-type tag; recognize it structurally instead. +//-------------------------------------------------------------------------------------------------- +bool RimWorkflowSchemaTools::isVec3Schema( const QJsonObject& typeSchema ) +{ + if ( typeSchema.value( "type" ).toString() != "object" ) return false; + + const QJsonObject properties = typeSchema.value( "properties" ).toObject(); + if ( properties.size() != 3 ) return false; + + for ( const QString& component : QStringList{ "x", "y", "z" } ) + { + if ( properties.value( component ).toObject().value( "type" ).toString() != "number" ) return false; + } + return true; +} + +//-------------------------------------------------------------------------------------------------- +/// Map a Python class path such as `rips.generated.generated_classes.WellPath` to the ResInsight +/// object type used to select the field binding. Returns an empty string for unsupported types. +//-------------------------------------------------------------------------------------------------- +QString RimWorkflowSchemaTools::resinsightTypeFromPythonType( const QString& pythonType ) +{ + const int separator = pythonType.lastIndexOf( '.' ); + if ( separator < 0 ) return {}; + + const QString module = pythonType.left( separator ); + if ( module != "rips" && !module.startsWith( "rips." ) ) return {}; + + static const QMap typeMap = { { "Case", "EclipseCase" }, + { "Reservoir", "EclipseCase" }, + { "EclipseCase", "EclipseCase" }, + { "WellPath", "WellPath" }, + { "View", "View" }, + { "EclipseView", "View" } }; + return typeMap.value( pythonType.mid( separator + 1 ) ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QStringList RimWorkflowSchemaTools::requiredFields( const QJsonObject& modelSchema ) +{ + QStringList fields; + for ( const QJsonValue& value : modelSchema.value( "required" ).toArray() ) + fields.append( value.toString() ); + return fields; +} + +//-------------------------------------------------------------------------------------------------- +/// Property names in schema order +//-------------------------------------------------------------------------------------------------- +QStringList RimWorkflowSchemaTools::propertyNames( const QJsonObject& modelSchema ) +{ + // QJsonObject sorts its keys; the `required` list and the original document keep model order + QStringList names; + const QJsonObject properties = modelSchema.value( "properties" ).toObject(); + for ( const QString& name : requiredFields( modelSchema ) ) + { + if ( properties.contains( name ) && !names.contains( name ) ) names.append( name ); + } + for ( const QString& name : properties.keys() ) + { + if ( !names.contains( name ) ) names.append( name ); + } + return names; +} + +//-------------------------------------------------------------------------------------------------- +/// Whether a value for the input property can be entered in ResInsight (a literal or an object reference) +//-------------------------------------------------------------------------------------------------- +bool RimWorkflowSchemaTools::isConfigurable( const QJsonObject& property, const QJsonObject& rootSchema ) +{ + const QJsonObject typeSchema = underlyingSchema( property, rootSchema ); + const QString pythonType = objectPythonType( typeSchema, rootSchema ); + if ( !pythonType.isEmpty() ) + { + // An object model with more required fields than `value` cannot be given as a single object + QStringList required = requiredFields( typeSchema ); + required.removeAll( "value" ); + return required.isEmpty() && !resinsightTypeFromPythonType( pythonType ).isEmpty(); + } + if ( typeSchema.value( "x-taskmaestro-opaque" ).toBool( false ) ) return false; + if ( isVec3Schema( typeSchema ) ) return true; + return typeSchema.value( "type" ).toString() != "object"; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QJsonObject RimWorkflowSchemaTools::configFieldSchema( const QString& fieldName, + const QJsonObject& inputSchema, + const QStringList& requiredFields, + const QJsonObject& configValues ) +{ + QJsonObject entry{ { "name", fieldName } }; + + const QJsonObject properties = inputSchema.value( "properties" ).toObject(); + if ( !properties.contains( fieldName ) ) + { + entry["type"] = "string"; + entry["required"] = true; + entry["error"] = "field not found in input model"; + return entry; + } + + const QJsonObject property = properties.value( fieldName ).toObject(); + const QJsonObject typeSchema = underlyingSchema( property, inputSchema ); + QString resinsightType = resinsightTypeFromPythonType( objectPythonType( typeSchema, inputSchema ) ); + if ( resinsightType.isEmpty() && isVec3Schema( typeSchema ) ) resinsightType = "Vec3"; + + entry["type"] = resinsightType.isEmpty() ? jsonType( typeSchema ) : "object"; + entry["required"] = requiredFields.contains( fieldName ); + + const QString description = property.value( "description" ).toString(); + if ( !description.isEmpty() ) entry["description"] = description; + + // Prefer the value already in input.yaml; fall back to the model default + if ( configValues.contains( fieldName ) ) + entry["default"] = configValues.value( fieldName ); + else if ( property.contains( "default" ) ) + entry["default"] = property.value( "default" ); + + const QString format = typeSchema.value( "format" ).toString(); + if ( !format.isEmpty() ) entry["format"] = format; + + if ( !resinsightType.isEmpty() ) entry["resinsight_type"] = resinsightType; + + return entry; +} diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowSchemaTools.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowSchemaTools.h new file mode 100644 index 0000000000..f1bb24357f --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowSchemaTools.h @@ -0,0 +1,46 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include +#include + +//================================================================================================== +/// Helpers for the JSON schemas that taskmaestro generates for task input and output models +//================================================================================================== +namespace RimWorkflowSchemaTools +{ +QJsonObject resolveRef( const QString& ref, const QJsonObject& rootSchema ); +QJsonObject resolveReferences( QJsonObject schema, const QJsonObject& rootSchema ); +QJsonObject underlyingSchema( QJsonObject schema, const QJsonObject& rootSchema ); +bool isOpaque( const QJsonObject& schema, const QJsonObject& rootSchema ); +QString objectPythonType( const QJsonObject& typeSchema, const QJsonObject& rootSchema ); +QString jsonType( const QJsonObject& typeSchema ); +bool isVec3Schema( const QJsonObject& typeSchema ); +QString resinsightTypeFromPythonType( const QString& pythonType ); +QStringList requiredFields( const QJsonObject& modelSchema ); +QStringList propertyNames( const QJsonObject& modelSchema ); +bool isConfigurable( const QJsonObject& property, const QJsonObject& rootSchema ); + +QJsonObject configFieldSchema( const QString& fieldName, + const QJsonObject& inputSchema, + const QStringList& requiredFields, + const QJsonObject& configValues ); +} // namespace RimWorkflowSchemaTools diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowStringBinding.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowStringBinding.cpp index fe1d742291..9cc076a303 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowStringBinding.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowStringBinding.cpp @@ -45,7 +45,7 @@ void RimWorkflowStringBinding::applySchema( const QJsonObject& fieldSchema ) //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -QString RimWorkflowStringBinding::toYamlValue() const +QJsonValue RimWorkflowStringBinding::toJsonValue() const { - return hasValue() ? yamlQuotedScalar( m_value() ) : "null"; + return hasValue() ? QJsonValue( m_value() ) : QJsonValue( QJsonValue::Null ); } diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowStringBinding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowStringBinding.h index aaabd427aa..14fc1bda80 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowStringBinding.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowStringBinding.h @@ -27,8 +27,8 @@ class RimWorkflowStringBinding : public RimWorkflowFieldBinding public: RimWorkflowStringBinding(); - void applySchema( const QJsonObject& fieldSchema ) override; - QString toYamlValue() const override; + void applySchema( const QJsonObject& fieldSchema ) override; + QJsonValue toJsonValue() const override; caf::PdmFieldHandle* valueField() override { return &m_value; } diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskCatalog.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskCatalog.cpp new file mode 100644 index 0000000000..f2dbd3552f --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskCatalog.cpp @@ -0,0 +1,192 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#include "RimWorkflowTaskCatalog.h" + +#include "RimWorkflowHelperProcess.h" + +#include "RiaLogging.h" + +#include + +namespace +{ +constexpr int catalogTimeoutMs = 120000; + +std::optional& cachedCatalog() +{ + static std::optional catalog; + return catalog; +} + +RimWorkflowTaskCatalog loadCatalog() +{ + auto result = RimWorkflowHelperProcess::runSync( { "catalog" }, {}, catalogTimeoutMs ); + if ( !result ) + { + RiaLogging::warning( QString( "Could not load the workflow task catalog: %1" ).arg( result.error() ).toStdString() ); + return RimWorkflowTaskCatalog::failed( result.error() ); + } + + auto catalog = RimWorkflowTaskCatalog::fromJson( result.value() ); + for ( const QString& error : catalog.pluginErrors() ) + RiaLogging::warning( QString( "Workflow plugin: %1" ).arg( error ).toStdString() ); + return catalog; +} + +QString pluginErrorText( const QJsonValue& error ) +{ + if ( error.isString() ) return error.toString(); + const QJsonObject object = error.toObject(); + const QString source = object.value( "id" ).toString( object.value( "entry_point" ).toString() ); + const QString text = object.value( "message" ).toString(); + return source.isEmpty() ? text : source + ": " + text; +} +} // namespace + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RimWorkflowTaskCatalog RimWorkflowTaskCatalog::fromJson( const QJsonObject& catalog ) +{ + RimWorkflowTaskCatalog result; + result.m_valid = true; + result.m_taskmaestroVersion = catalog.value( "taskmaestro_version" ).toString(); + result.m_workflows = catalog.value( "workflows" ).toArray(); + for ( const QJsonValue& error : catalog.value( "errors" ).toArray() ) + result.m_pluginErrors.append( pluginErrorText( error ) ); + + for ( const QJsonValue& value : catalog.value( "tasks" ).toArray() ) + { + const QJsonObject task = value.toObject(); + const QString id = task.value( "id" ).toString(); + if ( id.isEmpty() || result.m_taskTypes.contains( id ) ) continue; + result.m_taskTypes.insert( id, task ); + result.m_taskOrder.append( id ); + } + return result; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RimWorkflowTaskCatalog RimWorkflowTaskCatalog::failed( const QString& errorMessage ) +{ + RimWorkflowTaskCatalog result; + result.m_loadError = errorMessage; + return result; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +const RimWorkflowTaskCatalog& RimWorkflowTaskCatalog::instance() +{ + auto& catalog = cachedCatalog(); + if ( !catalog ) catalog = loadCatalog(); + return *catalog; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RimWorkflowTaskCatalog::invalidate() +{ + cachedCatalog().reset(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RimWorkflowTaskCatalog::setInstance( const RimWorkflowTaskCatalog& catalog ) +{ + cachedCatalog() = catalog; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RimWorkflowTaskCatalog::isValid() const +{ + return m_valid; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QString RimWorkflowTaskCatalog::loadError() const +{ + return m_loadError; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QStringList RimWorkflowTaskCatalog::pluginErrors() const +{ + return m_pluginErrors; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QString RimWorkflowTaskCatalog::taskmaestroVersion() const +{ + return m_taskmaestroVersion; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QJsonObject RimWorkflowTaskCatalog::taskType( const QString& taskId ) const +{ + return m_taskTypes.value( taskId ).toObject(); +} + +//-------------------------------------------------------------------------------------------------- +/// Task descriptors in catalog order +//-------------------------------------------------------------------------------------------------- +std::vector RimWorkflowTaskCatalog::tasks() const +{ + std::vector result; + result.reserve( m_taskOrder.size() ); + for ( const QString& id : m_taskOrder ) + result.push_back( m_taskTypes.value( id ).toObject() ); + return result; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QJsonArray RimWorkflowTaskCatalog::registeredWorkflows() const +{ + return m_workflows; +} + +//-------------------------------------------------------------------------------------------------- +/// Add task types (from a workflow definition) that are not in the catalog +//-------------------------------------------------------------------------------------------------- +void RimWorkflowTaskCatalog::merge( const QJsonObject& taskTypes ) +{ + for ( auto it = taskTypes.begin(); it != taskTypes.end(); ++it ) + { + if ( m_taskTypes.contains( it.key() ) || !it.value().isObject() ) continue; + m_taskTypes.insert( it.key(), it.value() ); + m_taskOrder.append( it.key() ); + } +} diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskCatalog.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskCatalog.h new file mode 100644 index 0000000000..2ffaca8bfd --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskCatalog.h @@ -0,0 +1,61 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include +#include +#include + +#include + +//================================================================================================== +/// Task types and registered workflows published by installed packages (`taskmaestro_helper catalog`). +/// The process-wide instance is loaded on first use and cached until `invalidate()` (Rescan). +//================================================================================================== +class RimWorkflowTaskCatalog +{ +public: + static RimWorkflowTaskCatalog fromJson( const QJsonObject& catalog ); + static RimWorkflowTaskCatalog failed( const QString& errorMessage ); + + static const RimWorkflowTaskCatalog& instance(); + static void invalidate(); + static void setInstance( const RimWorkflowTaskCatalog& catalog ); + + bool isValid() const; + QString loadError() const; + QStringList pluginErrors() const; + QString taskmaestroVersion() const; + + QJsonObject taskType( const QString& taskId ) const; + std::vector tasks() const; + QJsonArray registeredWorkflows() const; + + void merge( const QJsonObject& taskTypes ); + +private: + bool m_valid = false; + QString m_loadError; + QStringList m_pluginErrors; + QString m_taskmaestroVersion; + QJsonObject m_taskTypes; + QStringList m_taskOrder; + QJsonArray m_workflows; +}; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.cpp index 9935bbfae8..8cb85812f6 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.cpp @@ -15,7 +15,6 @@ // for more details. // ///////////////////////////////////////////////////////////////////////////////// - #include "RimWorkflowTaskInput.h" #include "RimWorkflowArrayBinding.h" @@ -25,45 +24,20 @@ #include "RimWorkflowFilePathBinding.h" #include "RimWorkflowFloatBinding.h" #include "RimWorkflowIntBinding.h" +#include "RimWorkflowMappingBinding.h" #include "RimWorkflowStringBinding.h" #include "RimWorkflowVec3Binding.h" #include "RimWorkflowViewBinding.h" #include "RimWorkflowWellPathBinding.h" #include "cafPdmUiOrdering.h" +#include "cafPdmUiTextEditor.h" #include "cafPdmUiTreeOrdering.h" #include -#include CAF_PDM_SOURCE_INIT( RimWorkflowTaskInput, "WorkflowTaskInput" ); -namespace -{ -//-------------------------------------------------------------------------------------------------- -/// -//-------------------------------------------------------------------------------------------------- -RimWorkflowFieldBinding* createBinding( const QJsonObject& schema ) -{ - const QString resinsightType = schema.value( "resinsight_type" ).toString(); - if ( resinsightType == "EclipseCase" ) return new RimWorkflowCaseBinding; - if ( resinsightType == "WellPath" ) return new RimWorkflowWellPathBinding; - if ( resinsightType == "View" ) return new RimWorkflowViewBinding; - if ( resinsightType == "Vec3" ) return new RimWorkflowVec3Binding; - - const QString type = schema.value( "type" ).toString( "string" ); - const QString format = schema.value( "format" ).toString(); - if ( type == "string" && format == "date" ) return new RimWorkflowDateBinding; - if ( type == "string" && ( format == "path" || format == "file-path" || format == "directory-path" ) ) - return new RimWorkflowFilePathBinding; - if ( type == "boolean" ) return new RimWorkflowBoolBinding; - if ( type == "integer" ) return new RimWorkflowIntBinding; - if ( type == "number" ) return new RimWorkflowFloatBinding; - if ( type == "array" ) return new RimWorkflowArrayBinding; - return new RimWorkflowStringBinding; -} -} // namespace - //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- @@ -74,6 +48,13 @@ RimWorkflowTaskInput::RimWorkflowTaskInput() CAF_PDM_InitFieldNoDefault( &m_taskName, "TaskName", "Task" ); m_taskName.uiCapability()->setUiReadOnly( true ); + + CAF_PDM_InitFieldNoDefault( &m_taskType, "TaskType", "Task Type" ); + m_taskType.uiCapability()->setUiReadOnly( true ); + + CAF_PDM_InitFieldNoDefault( &m_taskDescription, "TaskDescription", "Description" ); + m_taskDescription.uiCapability()->setUiReadOnly( true ); + m_taskDescription.uiCapability()->setUiEditorTypeName( caf::PdmUiTextEditor::uiEditorTypeName() ); } //-------------------------------------------------------------------------------------------------- @@ -93,19 +74,168 @@ void RimWorkflowTaskInput::setTaskName( const QString& name ) setUiName( name ); } +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QString RimWorkflowTaskInput::taskType() const +{ + return m_taskType(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RimWorkflowTaskInput::setTaskInfo( const QString& taskType, const QString& description ) +{ + m_taskType = taskType; + m_taskDescription = description; +} + +//-------------------------------------------------------------------------------------------------- +/// The class keyword of the binding used for a config field schema +//-------------------------------------------------------------------------------------------------- +QString RimWorkflowTaskInput::bindingClassKeyword( const QJsonObject& fieldSchema ) +{ + if ( fieldSchema.value( "map_over" ).toBool() ) return RimWorkflowMappingBinding::classKeywordStatic(); + + const QString resinsightType = fieldSchema.value( "resinsight_type" ).toString(); + if ( resinsightType == "EclipseCase" ) return RimWorkflowCaseBinding::classKeywordStatic(); + if ( resinsightType == "WellPath" ) return RimWorkflowWellPathBinding::classKeywordStatic(); + if ( resinsightType == "View" ) return RimWorkflowViewBinding::classKeywordStatic(); + if ( resinsightType == "Vec3" ) return RimWorkflowVec3Binding::classKeywordStatic(); + + const QString type = fieldSchema.value( "type" ).toString( "string" ); + const QString format = fieldSchema.value( "format" ).toString(); + if ( type == "string" && format == "date" ) return RimWorkflowDateBinding::classKeywordStatic(); + if ( type == "string" && ( format == "path" || format == "file-path" || format == "directory-path" ) ) + return RimWorkflowFilePathBinding::classKeywordStatic(); + if ( type == "boolean" ) return RimWorkflowBoolBinding::classKeywordStatic(); + if ( type == "integer" ) return RimWorkflowIntBinding::classKeywordStatic(); + if ( type == "number" ) return RimWorkflowFloatBinding::classKeywordStatic(); + if ( type == "array" ) return RimWorkflowArrayBinding::classKeywordStatic(); + return RimWorkflowStringBinding::classKeywordStatic(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RimWorkflowFieldBinding* RimWorkflowTaskInput::createBinding( const QJsonObject& fieldSchema ) +{ + const QString keyword = bindingClassKeyword( fieldSchema ); + if ( keyword == RimWorkflowCaseBinding::classKeywordStatic() ) return new RimWorkflowCaseBinding; + if ( keyword == RimWorkflowWellPathBinding::classKeywordStatic() ) return new RimWorkflowWellPathBinding; + if ( keyword == RimWorkflowViewBinding::classKeywordStatic() ) return new RimWorkflowViewBinding; + if ( keyword == RimWorkflowVec3Binding::classKeywordStatic() ) return new RimWorkflowVec3Binding; + if ( keyword == RimWorkflowDateBinding::classKeywordStatic() ) return new RimWorkflowDateBinding; + if ( keyword == RimWorkflowFilePathBinding::classKeywordStatic() ) return new RimWorkflowFilePathBinding; + if ( keyword == RimWorkflowBoolBinding::classKeywordStatic() ) return new RimWorkflowBoolBinding; + if ( keyword == RimWorkflowIntBinding::classKeywordStatic() ) return new RimWorkflowIntBinding; + if ( keyword == RimWorkflowFloatBinding::classKeywordStatic() ) return new RimWorkflowFloatBinding; + if ( keyword == RimWorkflowArrayBinding::classKeywordStatic() ) return new RimWorkflowArrayBinding; + if ( keyword == RimWorkflowMappingBinding::classKeywordStatic() ) return new RimWorkflowMappingBinding; + return new RimWorkflowStringBinding; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QString RimWorkflowTaskInput::detachedKey( const QString& fieldName, const QString& classKeyword ) +{ + return fieldName + "/" + classKeyword; +} + //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- void RimWorkflowTaskInput::buildFromSchema( const QJsonArray& configFields ) { + QMap noDetachedValues; deleteAllItems(); - for ( const QJsonValue& v : configFields ) + syncFromSchema( configFields, noDetachedValues ); +} + +//-------------------------------------------------------------------------------------------------- +/// Update the bindings to a new list of config fields. A binding is kept with its value when the +/// field still has the same kind of binding. Values of removed bindings are moved to +/// `detachedValues`, and restored from there when the field comes back. +//-------------------------------------------------------------------------------------------------- +void RimWorkflowTaskInput::syncFromSchema( const QJsonArray& configFields, QMap& detachedValues ) +{ + std::vector existing = items(); + m_items.clearWithoutDelete(); + + for ( const QJsonValue& value : configFields ) { - const QJsonObject schema = v.toObject(); - auto* binding = createBinding( schema ); + QJsonObject schema = value.toObject(); + const QString field = schema.value( "name" ).toString(); + const QString keyword = bindingClassKeyword( schema ); + + RimWorkflowFieldBinding* binding = nullptr; + for ( auto it = existing.begin(); it != existing.end(); ++it ) + { + if ( *it && ( *it )->fieldName() == field && ( *it )->classKeyword() == keyword ) + { + binding = *it; + existing.erase( it ); + break; + } + } + + if ( binding ) + { + const QJsonValue current = binding->toJsonValue(); + // Keep a cleared value cleared instead of restoring the default + if ( current.isNull() ) + schema.remove( "default" ); + else + schema["default"] = current; + } + else + { + binding = createBinding( schema ); + const QString key = detachedKey( field, keyword ); + if ( detachedValues.contains( key ) ) schema["default"] = detachedValues.take( key ); + } binding->applySchema( schema ); addItem( binding ); } + + for ( RimWorkflowFieldBinding* removed : existing ) + { + if ( !removed ) continue; + const QJsonValue value = removed->toJsonValue(); + if ( !value.isNull() ) detachedValues.insert( detachedKey( removed->fieldName(), removed->classKeyword() ), value ); + delete removed; + } +} + +//-------------------------------------------------------------------------------------------------- +/// All values that are set, including references to ResInsight objects +//-------------------------------------------------------------------------------------------------- +QJsonObject RimWorkflowTaskInput::jsonValues() const +{ + QJsonObject values; + for ( const RimWorkflowFieldBinding* binding : items() ) + { + const QJsonValue value = binding->toJsonValue(); + if ( !value.isNull() ) values.insert( binding->fieldName(), value ); + } + return values; +} + +//-------------------------------------------------------------------------------------------------- +/// Values that can be stored in input.yaml, without references to objects in the project +//-------------------------------------------------------------------------------------------------- +QJsonObject RimWorkflowTaskInput::literalValues() const +{ + QJsonObject values; + for ( const RimWorkflowFieldBinding* binding : items() ) + { + if ( binding->isObjectReference() ) continue; + const QJsonValue value = binding->toJsonValue(); + if ( !value.isNull() ) values.insert( binding->fieldName(), value ); + } + return values; } //-------------------------------------------------------------------------------------------------- @@ -113,9 +243,12 @@ void RimWorkflowTaskInput::buildFromSchema( const QJsonArray& configFields ) //-------------------------------------------------------------------------------------------------- void RimWorkflowTaskInput::defineUiOrdering( QString uiConfigName, caf::PdmUiOrdering& uiOrdering ) { - for ( RimWorkflowFieldBinding* b : items() ) + if ( !m_taskType().isEmpty() ) uiOrdering.add( &m_taskType ); + if ( !m_taskDescription().isEmpty() ) uiOrdering.add( &m_taskDescription ); + + for ( RimWorkflowFieldBinding* binding : items() ) { - if ( b && b->valueField() ) uiOrdering.add( b->valueField() ); + if ( binding && binding->valueField() ) uiOrdering.add( binding->valueField() ); } uiOrdering.skipRemainingFields( true ); } @@ -127,21 +260,3 @@ void RimWorkflowTaskInput::defineUiTreeOrdering( caf::PdmUiTreeOrdering& uiTreeO { uiTreeOrdering.skipRemainingChildren( true ); } - -//-------------------------------------------------------------------------------------------------- -/// -//-------------------------------------------------------------------------------------------------- -QString RimWorkflowTaskInput::toTaskYamlBlock() const -{ - QString out = m_taskName() + ":\n"; - if ( count() == 0 ) - { - out += " {}\n"; - return out; - } - for ( const RimWorkflowFieldBinding* b : items() ) - { - out += QString( " %1: %2\n" ).arg( b->fieldName(), b->toYamlValue() ); - } - return out; -} diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.h index 8a15fea8a1..9bb1163135 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowTaskInput.h @@ -15,15 +15,22 @@ // for more details. // ///////////////////////////////////////////////////////////////////////////////// - #pragma once #include "RimWorkflowFieldBinding.h" #include "cafPdmObjectCollection.h" +#include +#include +#include + class QJsonArray; +//================================================================================================== +/// The input values of one task instance in a job. There is one per task in the workflow; tasks +/// without config fields have no bindings and are hidden. +//================================================================================================== class RimWorkflowTaskInput : public caf::PdmObjectCollection { CAF_PDM_HEADER_INIT; @@ -33,10 +40,18 @@ public: QString taskName() const; void setTaskName( const QString& name ); + QString taskType() const; + void setTaskInfo( const QString& taskType, const QString& description ); void buildFromSchema( const QJsonArray& configFields ); + void syncFromSchema( const QJsonArray& configFields, QMap& detachedValues ); - QString toTaskYamlBlock() const; + QJsonObject jsonValues() const; + QJsonObject literalValues() const; + + static QString bindingClassKeyword( const QJsonObject& fieldSchema ); + static RimWorkflowFieldBinding* createBinding( const QJsonObject& fieldSchema ); + static QString detachedKey( const QString& fieldName, const QString& classKeyword ); protected: void defineUiOrdering( QString uiConfigName, caf::PdmUiOrdering& uiOrdering ) override; @@ -44,4 +59,6 @@ protected: private: caf::PdmField m_taskName; + caf::PdmField m_taskType; + caf::PdmField m_taskDescription; }; diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowValidationTools.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowValidationTools.cpp new file mode 100644 index 0000000000..156092ae36 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowValidationTools.cpp @@ -0,0 +1,149 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#include "RimWorkflowValidationTools.h" + +#include + +namespace +{ +RimWorkflowIssue errorIssue( const QString& task, const QString& field, const QString& message ) +{ + return { .task = task, .field = field, .severity = RimWorkflowIssue::Severity::Error, .message = message }; +} + +void appendErrorIssues( const QJsonObject& error, const QStringList& nodeNames, std::vector& issues ) +{ + QString message = error.value( "message" ).toString(); + if ( message.isEmpty() ) message = "The workflow is not valid"; + + QString task = error.value( "task" ).toString(); + if ( !nodeNames.contains( task ) ) task = RimWorkflowValidationTools::taskFromMessage( message, nodeNames ); + + const QJsonArray fieldIssues = error.value( "issues" ).toArray(); + if ( fieldIssues.isEmpty() || task.isEmpty() ) + { + issues.push_back( errorIssue( task, error.value( "field" ).toString(), message ) ); + return; + } + + for ( const QJsonValue& value : fieldIssues ) + { + const QJsonObject fieldIssue = value.toObject(); + const QString code = fieldIssue.value( "code" ).toString(); + issues.push_back( + errorIssue( task, fieldIssue.value( "field" ).toString(), code.isEmpty() ? message : QString( "%1 (%2)" ).arg( message, code ) ) ); + } +} +} // namespace + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RimWorkflowValidationTools::Result RimWorkflowValidationTools::issuesFromHelperResult( const QJsonObject& helperResult, + const QStringList& nodeNames ) +{ + if ( helperResult.value( "status" ).toString() == "invalid" ) + { + Result result; + result.describeSucceeded = false; + appendErrorIssues( helperResult.value( "error" ).toObject(), nodeNames, result.issues ); + return result; + } + return issuesFromDescribe( helperResult.value( "describe" ).toObject(), helperResult.value( "describe_error" ).toObject(), nodeNames ); +} + +//-------------------------------------------------------------------------------------------------- +/// Errors from describe, and warnings for config fields without a value +//-------------------------------------------------------------------------------------------------- +RimWorkflowValidationTools::Result RimWorkflowValidationTools::issuesFromDescribe( const QJsonObject& describe, + const QJsonObject& describeError, + const QStringList& nodeNames ) +{ + Result result; + if ( !describeError.isEmpty() ) + { + result.describeSucceeded = false; + appendErrorIssues( describeError, nodeNames, result.issues ); + return result; + } + + for ( const QJsonValue& value : describe.value( "tasks" ).toArray() ) + { + const QJsonObject task = value.toObject(); + for ( const QJsonValue& field : task.value( "missing_config_fields" ).toArray() ) + { + result.issues.push_back( { .task = task.value( "name" ).toString(), + .field = field.toString(), + .severity = RimWorkflowIssue::Severity::Warning, + .message = missingValueMessage( field.toString() ) } ); + } + } + return result; +} + +//-------------------------------------------------------------------------------------------------- +/// The node whose name is quoted in the message, preferring the longest name +//-------------------------------------------------------------------------------------------------- +QString RimWorkflowValidationTools::taskFromMessage( const QString& message, const QStringList& nodeNames ) +{ + QString found; + for ( const QString& name : nodeNames ) + { + const bool quoted = message.contains( QString( "'%1'" ).arg( name ) ) || message.contains( QString( "\"%1\"" ).arg( name ) ); + if ( quoted && name.size() > found.size() ) found = name; + } + return found; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QString RimWorkflowValidationTools::missingValueMessage( const QString& fieldName ) +{ + return QString( "Input '%1' has no value" ).arg( fieldName ); +} + +//-------------------------------------------------------------------------------------------------- +/// The saved definition only carries literal values, so describe reports object references (cases, +/// well paths, views) as missing, and the result goes stale when the user edits a value. The job's +/// own bindings are the source of truth for the fields they cover. +//-------------------------------------------------------------------------------------------------- +QJsonArray RimWorkflowValidationTools::issuesWithJobValues( const QString& taskName, + const QJsonArray& taskIssues, + const std::map& fieldHasValue ) +{ + QJsonArray result; + for ( const QJsonValue& value : taskIssues ) + { + const QJsonObject issue = value.toObject(); + const QString field = issue.value( "field" ).toString(); + const bool isMissingValueWarning = issue.value( "severity" ).toString() == "warning" && + issue.value( "message" ).toString() == missingValueMessage( field ); + if ( isMissingValueWarning && fieldHasValue.contains( field ) ) continue; + result.append( issue ); + } + + for ( const auto& [field, hasValue] : fieldHasValue ) + { + if ( hasValue ) continue; + result.append( + QJsonObject{ { "task", taskName }, { "field", field }, { "severity", "warning" }, { "message", missingValueMessage( field ) } } ); + } + return result; +} diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowValidationTools.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowValidationTools.h new file mode 100644 index 0000000000..a8522dfc55 --- /dev/null +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowValidationTools.h @@ -0,0 +1,50 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "RimWorkflowDefinition.h" + +#include +#include +#include + +#include +#include + +//================================================================================================== +/// Turns the result of `taskmaestro_helper save --describe` (or `load --describe`) into issues +/// attached to the tasks of the workflow. Issues with an empty task belong to the workflow. +//================================================================================================== +namespace RimWorkflowValidationTools +{ +struct Result +{ + std::vector issues; + bool describeSucceeded = true; +}; + +Result issuesFromHelperResult( const QJsonObject& helperResult, const QStringList& nodeNames ); +Result issuesFromDescribe( const QJsonObject& describe, const QJsonObject& describeError, const QStringList& nodeNames ); +QString taskFromMessage( const QString& message, const QStringList& nodeNames ); +QString missingValueMessage( const QString& fieldName ); + +// Replace the missing-value warnings of a task with warnings derived from the values of a job. +// `fieldHasValue` maps the task's bound input fields to whether the job gives them a value. +QJsonArray issuesWithJobValues( const QString& taskName, const QJsonArray& taskIssues, const std::map& fieldHasValue ); +} // namespace RimWorkflowValidationTools diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowVec3Binding.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowVec3Binding.cpp index 6ececb91aa..9ce974a1ff 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowVec3Binding.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowVec3Binding.cpp @@ -50,13 +50,10 @@ void RimWorkflowVec3Binding::applySchema( const QJsonObject& fieldSchema ) //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -QString RimWorkflowVec3Binding::toYamlValue() const +QJsonValue RimWorkflowVec3Binding::toJsonValue() const { - if ( !hasValue() ) return "null"; + if ( !hasValue() ) return QJsonValue::Null; const cvf::Vec3d& v = m_value(); - return QString( "{x: %1, y: %2, z: %3}" ) - .arg( QString::number( v.x(), 'g', 17 ) ) - .arg( QString::number( v.y(), 'g', 17 ) ) - .arg( QString::number( v.z(), 'g', 17 ) ); + return QJsonObject{ { "x", v.x() }, { "y", v.y() }, { "z", v.z() } }; } diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowVec3Binding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowVec3Binding.h index 4965312770..86fd586e28 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowVec3Binding.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowVec3Binding.h @@ -31,8 +31,8 @@ class RimWorkflowVec3Binding : public RimWorkflowFieldBinding public: RimWorkflowVec3Binding(); - void applySchema( const QJsonObject& fieldSchema ) override; - QString toYamlValue() const override; + void applySchema( const QJsonObject& fieldSchema ) override; + QJsonValue toJsonValue() const override; caf::PdmFieldHandle* valueField() override { return &m_value; } diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowViewBinding.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowViewBinding.cpp index 79604480bc..bc6fbdab23 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowViewBinding.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowViewBinding.cpp @@ -23,6 +23,8 @@ #include "RimEclipseView.h" #include "RimProject.h" +#include + CAF_PDM_SOURCE_INIT( RimWorkflowViewBinding, "WorkflowViewBinding" ); //-------------------------------------------------------------------------------------------------- @@ -44,10 +46,10 @@ QString RimWorkflowViewBinding::displayValue() const //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -QString RimWorkflowViewBinding::toYamlValue() const +QJsonValue RimWorkflowViewBinding::toJsonValue() const { - if ( m_view() == nullptr ) return "null"; - return QString( "{__resinsight_ref__: View, view_id: %1}" ).arg( m_view()->id() ); + if ( m_view() == nullptr ) return QJsonValue::Null; + return QJsonObject{ { "__resinsight_ref__", "View" }, { "view_id", m_view()->id() } }; } //-------------------------------------------------------------------------------------------------- @@ -73,3 +75,31 @@ QList RimWorkflowViewBinding::calculateValueOptions( con } return options; } + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RimWorkflowViewBinding::isObjectReference() const +{ + return true; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RimWorkflowViewBinding::applySchema( const QJsonObject& fieldSchema ) +{ + RimWorkflowFieldBinding::applySchema( fieldSchema ); + const QJsonObject reference = fieldSchema.value( "default" ).toObject(); + if ( reference.value( "__resinsight_ref__" ).toString() != "View" || !reference.contains( "view_id" ) ) return; + + const int viewId = reference.value( "view_id" ).toInt( -1 ); + for ( RimEclipseCase* eclipseCase : RimEclipseCaseTools::eclipseCases() ) + { + if ( !eclipseCase ) continue; + for ( RimEclipseView* view : eclipseCase->reservoirViews() ) + { + if ( view && view->id() == viewId ) m_view = view; + } + } +} diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowViewBinding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowViewBinding.h index b10b07eea6..b334440c90 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowViewBinding.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowViewBinding.h @@ -31,8 +31,10 @@ class RimWorkflowViewBinding : public RimWorkflowFieldBinding public: RimWorkflowViewBinding(); - QString toYamlValue() const override; - QString displayValue() const override; + QJsonValue toJsonValue() const override; + bool isObjectReference() const override; + void applySchema( const QJsonObject& fieldSchema ) override; + QString displayValue() const override; caf::PdmFieldHandle* valueField() override { return &m_view; } diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowWellPathBinding.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowWellPathBinding.cpp index 39d667645f..a15590e177 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowWellPathBinding.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowWellPathBinding.cpp @@ -18,9 +18,12 @@ #include "RimWorkflowWellPathBinding.h" +#include "RimProject.h" #include "RimTools.h" #include "RimWellPath.h" +#include + CAF_PDM_SOURCE_INIT( RimWorkflowWellPathBinding, "WorkflowWellPathBinding" ); //-------------------------------------------------------------------------------------------------- @@ -42,11 +45,10 @@ QString RimWorkflowWellPathBinding::displayValue() const //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -QString RimWorkflowWellPathBinding::toYamlValue() const +QJsonValue RimWorkflowWellPathBinding::toJsonValue() const { - if ( m_wellPath() == nullptr ) return "null"; - const QString name = yamlQuotedScalar( m_wellPath()->name() ); - return QString( "{__resinsight_ref__: WellPath, well_path_name: %1}" ).arg( name ); + if ( m_wellPath() == nullptr ) return QJsonValue::Null; + return QJsonObject{ { "__resinsight_ref__", "WellPath" }, { "well_path_name", m_wellPath()->name() } }; } //-------------------------------------------------------------------------------------------------- @@ -58,3 +60,29 @@ QList RimWorkflowWellPathBinding::calculateValueOptions( if ( fieldNeedingOptions == &m_wellPath ) RimTools::wellPathOptionItems( &options ); return options; } + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RimWorkflowWellPathBinding::isObjectReference() const +{ + return true; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RimWorkflowWellPathBinding::applySchema( const QJsonObject& fieldSchema ) +{ + RimWorkflowFieldBinding::applySchema( fieldSchema ); + const QJsonObject reference = fieldSchema.value( "default" ).toObject(); + if ( reference.value( "__resinsight_ref__" ).toString() != "WellPath" || !reference.contains( "well_path_name" ) ) return; + + auto* project = RimProject::current(); + if ( !project ) return; + const QString name = reference.value( "well_path_name" ).toString(); + for ( RimWellPath* wellPath : project->allWellPaths() ) + { + if ( wellPath && wellPath->name() == name ) m_wellPath = wellPath; + } +} diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowWellPathBinding.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowWellPathBinding.h index 7ed96f07f6..77ca73c8a6 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowWellPathBinding.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowWellPathBinding.h @@ -31,8 +31,10 @@ class RimWorkflowWellPathBinding : public RimWorkflowFieldBinding public: RimWorkflowWellPathBinding(); - QString toYamlValue() const override; - QString displayValue() const override; + QJsonValue toJsonValue() const override; + bool isObjectReference() const override; + void applySchema( const QJsonObject& fieldSchema ) override; + QString displayValue() const override; caf::PdmFieldHandle* valueField() override { return &m_wellPath; } diff --git a/ApplicationLibCode/UnitTests/CMakeLists.txt b/ApplicationLibCode/UnitTests/CMakeLists.txt index c4613fa2f7..71a3f17389 100644 --- a/ApplicationLibCode/UnitTests/CMakeLists.txt +++ b/ApplicationLibCode/UnitTests/CMakeLists.txt @@ -106,6 +106,9 @@ set(SOURCE_UNITTEST_FILES ${CMAKE_CURRENT_LIST_DIR}/RimWellTargetMapping-Test.cpp ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowDescribeTools-Test.cpp ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowVec3Binding-Test.cpp + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowDefinition-Test.cpp + ${CMAKE_CURRENT_LIST_DIR}/RimWorkflowJobSync-Test.cpp + ${CMAKE_CURRENT_LIST_DIR}/RiuWorkflowGraphLayout-Test.cpp ${CMAKE_CURRENT_LIST_DIR}/RimDataFilterCollection-Test.cpp ${CMAKE_CURRENT_LIST_DIR}/RimEclipseContourMapProjection-Test.cpp ${CMAKE_CURRENT_LIST_DIR}/RimIntersectionCollection-Test.cpp diff --git a/ApplicationLibCode/UnitTests/RimWorkflowDefinition-Test.cpp b/ApplicationLibCode/UnitTests/RimWorkflowDefinition-Test.cpp new file mode 100644 index 0000000000..f07fdbc650 --- /dev/null +++ b/ApplicationLibCode/UnitTests/RimWorkflowDefinition-Test.cpp @@ -0,0 +1,609 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#include "gtest/gtest.h" + +#include "RiaTestDataDirectory.h" +#include "RimWorkflowDefinitionTools.h" +#include "RimWorkflowPortCompatibility.h" +#include "RimWorkflowTaskCatalog.h" + +#include +#include +#include +#include + +// +// Fixtures in TestData/RimWorkflowDefinition are produced by the Python helper (run from +// GrpcInterface/Python, with absolute paths replaced by `test_data/taskmaestro_workflows`): +// python -m rips.taskmaestro_helper load rips/tests/test_data/taskmaestro_workflows/resinsight_completions --describe > completions.json +// python -m rips.taskmaestro_helper load rips/tests/test_data/taskmaestro_workflows/resinsight_load_data --describe > load_data.json +// python -m rips.taskmaestro_helper catalog > catalog.json +// +namespace +{ +QJsonObject fixture( const QString& name ) +{ + QFile file( QString( "%1/RimWorkflowDefinition/%2.json" ).arg( TEST_DATA_DIR ).arg( name ) ); + if ( !file.open( QIODevice::ReadOnly ) ) return {}; + return QJsonDocument::fromJson( file.readAll() ).object(); +} + +RimWorkflowDefinition definitionFixture( const QString& name ) +{ + auto definition = RimWorkflowDefinitionTools::fromJson( fixture( name ) ); + EXPECT_TRUE( definition.has_value() ); + return definition.value_or( RimWorkflowDefinition() ); +} + +RimWorkflowTaskCatalog catalogFixture() +{ + return RimWorkflowTaskCatalog::fromJson( fixture( "catalog" ) ); +} + +QJsonObject taskByName( const QJsonObject& graph, const QString& name ) +{ + for ( const QJsonValue& task : graph.value( "tasks" ).toArray() ) + { + if ( task.toObject().value( "name" ).toString() == name ) return task.toObject(); + } + return {}; +} + +RimWorkflowDefinition edited( const RimWorkflowDefinitionTools::EditResult& result ) +{ + EXPECT_TRUE( result.has_value() ) << ( result ? "" : result.error().toStdString() ); + return result.value_or( RimWorkflowDefinition() ); +} + +bool hasEdge( const RimWorkflowDefinition& definition, const RimWorkflowDefinitionEdge& edge ) +{ + return std::find( definition.edges.begin(), definition.edges.end(), edge ) != definition.edges.end(); +} + +QStringList issueMessages( const std::vector& issues ) +{ + QStringList messages; + for ( const auto& issue : issues ) + messages.append( QString( "%1.%2: %3" ).arg( issue.task, issue.field, issue.message ) ); + return messages; +} +} // namespace + +//-------------------------------------------------------------------------------------------------- +TEST( RimWorkflowDefinition, fromJson ) +{ + const auto definition = definitionFixture( "completions" ); + EXPECT_EQ( "resinsight_completions", definition.name ); + EXPECT_TRUE( definition.editable ); + ASSERT_EQ( 7u, definition.nodes.size() ); + EXPECT_EQ( 13u, definition.edges.size() ); + EXPECT_TRUE( definition.resultTask.isEmpty() ); + + const auto* addPerf = definition.findNode( "add_perf_1" ); + ASSERT_TRUE( addPerf ); + EXPECT_EQ( "resinsight.add_perforation", addPerf->taskId ); + EXPECT_EQ( QStringList( { "event_date", "start_md", "end_md" } ), addPerf->configFields ); + EXPECT_EQ( addPerf->configFields, addPerf->explicitConfigFields ); + EXPECT_FALSE( definition.describe.isEmpty() ); + + EXPECT_FALSE( RimWorkflowDefinitionTools::fromJson( QJsonObject{ { "format", 2 }, { "nodes", QJsonArray() } } ).has_value() ); + EXPECT_FALSE( RimWorkflowDefinitionTools::fromJson( QJsonObject{ { "name", "x" } } ).has_value() ); +} + +//-------------------------------------------------------------------------------------------------- +TEST( RimWorkflowDefinition, jsonRoundTrip ) +{ + for ( const QString& name : { "completions", "load_data" } ) + { + const auto definition = definitionFixture( name ); + const auto roundTrip = RimWorkflowDefinitionTools::fromJson( RimWorkflowDefinitionTools::toJson( definition ) ); + ASSERT_TRUE( roundTrip.has_value() ); + EXPECT_EQ( definition, roundTrip.value() ) << name.toStdString(); + } +} + +//-------------------------------------------------------------------------------------------------- +TEST( RimWorkflowDefinition, normalizeKeepsLoadedExamples ) +{ + for ( const QString& name : { "completions", "load_data" } ) + { + const auto definition = definitionFixture( name ); + const auto normalized = RimWorkflowDefinitionTools::normalize( definition ); + for ( size_t i = 0; i < definition.nodes.size(); ++i ) + { + EXPECT_EQ( definition.nodes[i].configFields, normalized.nodes[i].configFields ) << definition.nodes[i].name.toStdString(); + } + EXPECT_EQ( normalized, RimWorkflowDefinitionTools::normalize( normalized ) ); + EXPECT_TRUE( RimWorkflowDefinitionTools::validate( definition ).empty() ) + << issueMessages( RimWorkflowDefinitionTools::validate( definition ) ).join( "\n" ).toStdString(); + } +} + +//-------------------------------------------------------------------------------------------------- +TEST( RimWorkflowDefinition, graphFromDefinition ) +{ + const auto definition = definitionFixture( "completions" ); + const auto graph = RimWorkflowDefinitionTools::graphFromDefinition( definition, true ); + EXPECT_EQ( "resinsight_completions", graph.value( "name" ).toString() ); + EXPECT_TRUE( graph.value( "editable" ).toBool() ); + EXPECT_EQ( "export_completions", graph.value( "result_task" ).toString() ); + EXPECT_EQ( 7, graph.value( "tasks" ).toArray().size() ); + EXPECT_EQ( 13, graph.value( "edges" ).toArray().size() ); + EXPECT_TRUE( graph.value( "issues" ).toArray().isEmpty() ); + + const QJsonObject exportTask = taskByName( graph, "export_completions" ); + EXPECT_TRUE( exportTask.value( "is_result" ).toBool() ); + EXPECT_EQ( 2, exportTask.value( "config_fields" ).toArray().size() ); + EXPECT_EQ( QJsonArray( { "export_file", "well_path_names" } ), exportTask.value( "outputs" ).toArray() ); + + // The whole input port comes first, followed by the fields + const QJsonArray ports = exportTask.value( "input_ports" ).toArray(); + ASSERT_EQ( 7, ports.size() ); + EXPECT_EQ( "", ports[0].toObject().value( "name" ).toString() ); + EXPECT_EQ( "resinsight", ports[1].toObject().value( "name" ).toString() ); + EXPECT_TRUE( ports[1].toObject().value( "wired" ).toBool() ); + + // Type names for labelling the ports + EXPECT_EQ( "RipsInstance", exportTask.value( "input_types" ).toObject().value( "resinsight" ).toString() ); + EXPECT_EQ( "str", exportTask.value( "output_types" ).toObject().value( "export_file" ).toString() ); + + // Icons of ResInsight objects, found through `ObjectModel[Class]` and rips bases + const QJsonObject exportIcons = exportTask.value( "input_icons" ).toObject(); + EXPECT_EQ( ":/AppLogo48x48.png", exportIcons.value( "resinsight" ).toString() ); + EXPECT_EQ( ":/codicons/symbol-string.svg", exportTask.value( "output_icons" ).toObject().value( "export_file" ).toString() ); + EXPECT_EQ( ":/Case48x48.png", taskByName( graph, "select_eclipse_case" ).value( "output_icons" ).toObject().value( "" ).toString() ); + EXPECT_EQ( ":/Well.svg", taskByName( graph, "select_well_path_1" ).value( "output_icons" ).toObject().value( "" ).toString() ); + EXPECT_EQ( ":/Case48x48.png", + RimWorkflowPortCompatibility::iconResource( QJsonObject{ { "x-ri-python-type", "rips.generated.generated_classes.Reservoir" } }, + {} ) ); + EXPECT_EQ( ":/3DView16x16.png", + RimWorkflowPortCompatibility::iconResource( QJsonObject{ { "anyOf", + QJsonArray{ QJsonObject{ { "type", "null" } }, + QJsonObject{ { "x-ri-python-bases", + QJsonArray{ "pkg.GridView", + "taskmaestro.object_model." + "ObjectModel[" + "View]" } } } } } }, + {} ) ); + + // Plain values get an icon for their kind; models without a ResInsight object get none + auto valueIcon = []( const QJsonObject& schema ) { return RimWorkflowPortCompatibility::iconResource( schema, {} ); }; + EXPECT_EQ( ":/codicons/symbol-string.svg", valueIcon( { { "type", "string" } } ) ); + EXPECT_EQ( ":/codicons/symbol-numeric.svg", valueIcon( { { "type", "integer" } } ) ); + EXPECT_EQ( ":/codicons/symbol-numeric.svg", valueIcon( { { "type", "number" } } ) ); + EXPECT_EQ( ":/codicons/calendar.svg", valueIcon( { { "type", "string" }, { "format", "date" } } ) ); + EXPECT_EQ( ":/codicons/file.svg", valueIcon( { { "type", "string" }, { "format", "path" } } ) ); + EXPECT_EQ( ":/codicons/folder.svg", valueIcon( { { "type", "string" }, { "format", "directory-path" } } ) ); + EXPECT_EQ( ":/codicons/symbol-numeric.svg", + valueIcon( { { "anyOf", QJsonArray{ QJsonObject{ { "type", "number" } }, QJsonObject{ { "type", "null" } } } } } ) ); + EXPECT_EQ( ":/codicons/symbol-string.svg", valueIcon( { { "type", "array" }, { "items", QJsonObject{ { "type", "string" } } } } ) ); + EXPECT_EQ( ":/codicons/check.svg", valueIcon( { { "type", "boolean" } } ) ); + EXPECT_TRUE( + valueIcon( { { "type", "object" }, { "properties", QJsonObject{ { "a", QJsonObject{ { "type", "string" } } } } } } ).isEmpty() ); + + // The connect task's opaque `value` is not a port of its own + const QJsonObject connect = taskByName( graph, "connect_to_resinsight" ); + EXPECT_TRUE( connect.value( "outputs" ).toArray().isEmpty() ); + EXPECT_FALSE( connect.value( "accepts_input" ).toBool() ); + + EXPECT_FALSE( RimWorkflowDefinitionTools::graphFromDefinition( definition, false ).value( "editable" ).toBool() ); +} + +//-------------------------------------------------------------------------------------------------- +TEST( RimWorkflowPortCompatibility, fieldConnections ) +{ + const auto definition = definitionFixture( "completions" ); + using RimWorkflowPortCompatibility::canConnect; + + EXPECT_TRUE( canConnect( definition, "connect_to_resinsight", "", "select_eclipse_case", "resinsight" ) ); + EXPECT_TRUE( canConnect( definition, "select_eclipse_case", "", "select_well_path_1", "grid_case" ) ); + + // A GridCase is not a RipsInstance + EXPECT_FALSE( canConnect( definition, "select_eclipse_case", "", "select_well_path_1", "resinsight" ) ); + // A perforation is not a well path + EXPECT_FALSE( canConnect( definition, "add_perf_1", "", "add_perf_2", "well_path" ) ); + // Cycles and self-loops + EXPECT_FALSE( canConnect( definition, "export_completions", "", "select_eclipse_case", "case" ) ); + EXPECT_FALSE( canConnect( definition, "add_perf_1", "", "add_perf_1", "well_path" ) ); + // Unknown ports and tasks + EXPECT_FALSE( canConnect( definition, "select_eclipse_case", "", "select_well_path_1", "no_such_input" ) ); + EXPECT_FALSE( canConnect( definition, "no_such_task", "", "select_well_path_1", "grid_case" ) ); + // A task cannot take both its whole input and fields from other tasks + EXPECT_FALSE( canConnect( definition, "select_well_path_1", "", "add_perf_1", "" ) ); +} + +//-------------------------------------------------------------------------------------------------- +TEST( RimWorkflowPortCompatibility, wholeConnections ) +{ + const auto catalog = catalogFixture(); + auto definition = RimWorkflowDefinitionTools::createEmpty( "test" ); + for ( const QString& taskId : { "resinsight.connect", "resinsight.load_model", "resinsight.close_project", "resinsight.exit" } ) + { + definition.taskTypes.insert( taskId, catalog.taskType( taskId ) ); + definition.nodes.push_back( { .name = taskId.mid( 11 ), .taskId = taskId } ); + } + using RimWorkflowPortCompatibility::canConnect; + + // RipsInstance -> close_project (RipsInstance) and exit (RipsInstance) + EXPECT_TRUE( canConnect( definition, "connect", "", "close_project", "" ) ); + EXPECT_TRUE( canConnect( definition, "close_project", "", "exit", "" ) ); + // GridCase -> RipsInstance is not compatible + EXPECT_FALSE( canConnect( definition, "load_model", "", "close_project", "" ) ); + // Whole RipsInstance into a field input + EXPECT_TRUE( canConnect( definition, "connect", "", "load_model", "resinsight" ) ); +} + +//-------------------------------------------------------------------------------------------------- +TEST( RimWorkflowDefinitionEdit, addTaskWiresResInsight ) +{ + const auto catalog = catalogFixture(); + auto definition = RimWorkflowDefinitionTools::createEmpty( "new_workflow" ); + + QString added; + definition = edited( RimWorkflowDefinitionTools::addTask( definition, catalog, "resinsight.load_model", &added ) ); + EXPECT_EQ( "load_model", added ); + ASSERT_EQ( 2u, definition.nodes.size() ); + EXPECT_EQ( "connect_to_resinsight", definition.nodes[0].name ); + EXPECT_TRUE( hasEdge( definition, { .from = "connect_to_resinsight", .to = "load_model", .input = "resinsight" } ) ); + EXPECT_EQ( QStringList( { "path" } ), definition.findNode( "load_model" )->configFields ); + EXPECT_TRUE( definition.inputs.contains( "load_model" ) ); + EXPECT_TRUE( definition.taskTypes.contains( "resinsight.load_model" ) ); + + // A second instance gets a unique name and reuses the connect task + definition = edited( RimWorkflowDefinitionTools::addTask( definition, catalog, "resinsight.load_model", &added ) ); + EXPECT_EQ( "load_model_2", added ); + EXPECT_EQ( 3u, definition.nodes.size() ); + EXPECT_TRUE( hasEdge( definition, { .from = "connect_to_resinsight", .to = "load_model_2", .input = "resinsight" } ) ); + + // Two end tasks and no result task + auto issues = RimWorkflowDefinitionTools::validate( definition ); + ASSERT_EQ( 1u, issues.size() ) << issueMessages( issues ).join( "\n" ).toStdString(); + EXPECT_TRUE( issues[0].task.isEmpty() ); + + definition = edited( RimWorkflowDefinitionTools::setResultTask( definition, "load_model_2" ) ); + EXPECT_TRUE( RimWorkflowDefinitionTools::validate( definition ).empty() ); + + // The exit task takes the whole RipsInstance + definition = edited( RimWorkflowDefinitionTools::addTask( definition, catalog, "resinsight.exit", &added ) ); + EXPECT_EQ( "exit_resinsight", added ); + EXPECT_TRUE( hasEdge( definition, { .from = "connect_to_resinsight", .to = "exit_resinsight" } ) ); + EXPECT_TRUE( definition.findNode( "exit_resinsight" )->configFields.isEmpty() ); + + EXPECT_FALSE( RimWorkflowDefinitionTools::addTask( definition, catalog, "no.such.task" ).has_value() ); +} + +//-------------------------------------------------------------------------------------------------- +TEST( RimWorkflowDefinitionEdit, connectionsUpdateConfigFields ) +{ + auto definition = definitionFixture( "completions" ); + const RimWorkflowDefinitionEdge gridCase{ .from = "select_eclipse_case", .to = "select_well_path_1", .input = "grid_case" }; + + // A disconnected required input that can be entered in ResInsight becomes a config field + definition = edited( RimWorkflowDefinitionTools::disconnect( definition, gridCase ) ); + EXPECT_EQ( QStringList( { "well_path", "grid_case" } ), definition.findNode( "select_well_path_1" )->configFields ); + EXPECT_FALSE( RimWorkflowDefinitionTools::disconnect( definition, gridCase ).has_value() ); + + definition = edited( RimWorkflowDefinitionTools::connect( definition, gridCase ) ); + EXPECT_EQ( QStringList( { "well_path" } ), definition.findNode( "select_well_path_1" )->configFields ); + + // Connecting an input that is already connected replaces the connection + definition = edited( + RimWorkflowDefinitionTools::connect( definition, { .from = "add_perf_2", .to = "export_completions", .input = "perforation_1" } ) ); + EXPECT_FALSE( hasEdge( definition, { .from = "add_perf_1", .to = "export_completions", .input = "perforation_1" } ) ); + EXPECT_EQ( 13u, definition.edges.size() ); + + // add_perf_1 is now an end task, and the perforation input of export is not configurable + auto issues = RimWorkflowDefinitionTools::validate( definition ); + EXPECT_EQ( 1u, issues.size() ) << issueMessages( issues ).join( "\n" ).toStdString(); + + definition = edited( + RimWorkflowDefinitionTools::disconnect( definition, { .from = "add_perf_2", .to = "export_completions", .input = "perforation_1" } ) ); + issues = RimWorkflowDefinitionTools::validate( definition ); + bool mustConnect = false; + for ( const auto& issue : issues ) + mustConnect = mustConnect || ( issue.task == "export_completions" && issue.field == "perforation_1" ); + EXPECT_TRUE( mustConnect ) << issueMessages( issues ).join( "\n" ).toStdString(); + + const auto graph = RimWorkflowDefinitionTools::graphFromDefinition( definition, true ); + EXPECT_FALSE( taskByName( graph, "export_completions" ).value( "issues" ).toArray().isEmpty() ); +} + +//-------------------------------------------------------------------------------------------------- +TEST( RimWorkflowDefinitionEdit, removeAndRename ) +{ + auto definition = definitionFixture( "load_data" ); + EXPECT_EQ( "import_grid_property", definition.resultTask ); + + definition.inputs["load_model"] = QJsonObject{ { "path", "/data/grid.roff" } }; + definition = edited( RimWorkflowDefinitionTools::renameTask( definition, "load_model", "load_grid" ) ); + EXPECT_TRUE( definition.findNode( "load_grid" ) ); + EXPECT_FALSE( definition.inputs.contains( "load_model" ) ); + EXPECT_EQ( "/data/grid.roff", definition.inputs["load_grid"].toObject().value( "path" ).toString() ); + EXPECT_TRUE( hasEdge( definition, { .from = "load_grid", .to = "import_grid_property", .input = "grid_case" } ) ); + + definition = edited( RimWorkflowDefinitionTools::renameTask( definition, "import_grid_property", "import_props" ) ); + EXPECT_EQ( "import_props", definition.resultTask ); + + EXPECT_FALSE( RimWorkflowDefinitionTools::renameTask( definition, "load_grid", "import_props" ).has_value() ); + EXPECT_FALSE( RimWorkflowDefinitionTools::renameTask( definition, "load_grid", "2bad" ).has_value() ); + EXPECT_FALSE( RimWorkflowDefinitionTools::renameTask( definition, "load_grid", "with space" ).has_value() ); + EXPECT_FALSE( RimWorkflowDefinitionTools::renameWorkflow( definition, "bad-name" ).has_value() ); + EXPECT_EQ( "renamed", edited( RimWorkflowDefinitionTools::renameWorkflow( definition, "renamed" ) ).name ); + + definition = edited( RimWorkflowDefinitionTools::removeTask( definition, "import_props" ) ); + EXPECT_TRUE( definition.resultTask.isEmpty() ); + EXPECT_FALSE( definition.inputs.contains( "import_props" ) ); + for ( const auto& edge : definition.edges ) + EXPECT_NE( "import_props", edge.to ); +} + +//-------------------------------------------------------------------------------------------------- +TEST( RimWorkflowDefinitionEdit, optionalInputs ) +{ + const auto catalog = catalogFixture(); + auto definition = RimWorkflowDefinitionTools::createEmpty( "views" ); + QString added; + definition = edited( RimWorkflowDefinitionTools::addTask( definition, catalog, "resinsight.configure_grid_view", &added ) ); + EXPECT_FALSE( definition.findNode( added )->configFields.contains( "time_step" ) ); + + definition = edited( RimWorkflowDefinitionTools::setOptionalInputConfigured( definition, added, "time_step", true ) ); + EXPECT_TRUE( definition.findNode( added )->configFields.contains( "time_step" ) ); + definition = edited( RimWorkflowDefinitionTools::setOptionalInputConfigured( definition, added, "time_step", false ) ); + EXPECT_FALSE( definition.findNode( added )->configFields.contains( "time_step" ) ); + + EXPECT_FALSE( RimWorkflowDefinitionTools::setOptionalInputConfigured( definition, added, "result_type", true ).has_value() ); +} + +//-------------------------------------------------------------------------------------------------- +TEST( RimWorkflowDefinitionEdit, readOnlyDefinitionsAreNotEdited ) +{ + auto definition = definitionFixture( "load_data" ); + definition.editable = false; + EXPECT_FALSE( RimWorkflowDefinitionTools::removeTask( definition, "load_model" ).has_value() ); + EXPECT_FALSE( RimWorkflowDefinitionTools::renameWorkflow( definition, "other" ).has_value() ); +} + +//-------------------------------------------------------------------------------------------------- +TEST( RimWorkflowDefinitionEdit, names ) +{ + EXPECT_TRUE( RimWorkflowDefinitionTools::isIdentifier( "load_model_2" ) ); + EXPECT_FALSE( RimWorkflowDefinitionTools::isIdentifier( "2load" ) ); + EXPECT_FALSE( RimWorkflowDefinitionTools::isIdentifier( "" ) ); + EXPECT_EQ( "resinsight_load_model", RimWorkflowDefinitionTools::identifierFrom( "resinsight.load-model" ) ); + EXPECT_EQ( "_3d", RimWorkflowDefinitionTools::identifierFrom( "3d" ) ); +} + +namespace +{ +QJsonObject numberModel( const QString& title, const QString& field, const QJsonObject& fieldSchema ) +{ + return QJsonObject{ { "type", "object" }, + { "title", title }, + { "properties", QJsonObject{ { field, fieldSchema } } }, + { "required", QJsonArray{ field } } }; +} + +// produce(name: str) -> value: float; scale(name: str, factor: float) -> value: float; +// sum_list(values: list[float]); sum_dict(values: dict[str, float]) +RimWorkflowDefinition collectDefinition() +{ + const QJsonObject number{ { "type", "number" } }; + const QJsonObject output = numberModel( "Value", "value", number ); + const QJsonObject nameInput{ { "type", "object" }, + { "title", "Named" }, + { "properties", QJsonObject{ { "name", QJsonObject{ { "type", "string" } } }, { "factor", number } } }, + { "required", QJsonArray{ "name", "factor" } } }; + + auto definition = RimWorkflowDefinitionTools::createEmpty( "collect" ); + definition.taskTypes.insert( "t.produce", QJsonObject{ { "name", "produce" }, { "input_schema", nameInput }, { "output_schema", output } } ); + definition.taskTypes.insert( "t.sum_list", + QJsonObject{ { "name", "sum_list" }, + { "input_schema", + numberModel( "ListInput", "values", QJsonObject{ { "type", "array" }, { "items", number } } ) }, + { "output_schema", output } } ); + definition.taskTypes.insert( "t.sum_dict", + QJsonObject{ { "name", "sum_dict" }, + { "input_schema", + numberModel( "DictInput", + "values", + QJsonObject{ { "type", "object" }, { "additionalProperties", number } } ) }, + { "output_schema", output } } ); + for ( const auto& [name, taskId] : std::vector>{ { "a", "t.produce" }, + { "b", "t.produce" }, + { "c", "t.produce" }, + { "sum_list", "t.sum_list" }, + { "sum_dict", "t.sum_dict" } } ) + { + definition.nodes.push_back( { .name = name, .taskId = taskId } ); + definition.inputs.insert( name, QJsonObject() ); + } + definition.taskTypes.insert( "t.gather", + QJsonObject{ { "name", "gather" }, + { "input_schema", + QJsonObject{ { "type", "object" }, + { "title", "GatherInput" }, + { "properties", + QJsonObject{ { "results", + QJsonObject{ { "type", "object" }, + { "additionalProperties", + QJsonObject{ { "$ref", "#/$defs/Value" } } } } } } }, + { "required", QJsonArray{ "results" } }, + { "$defs", QJsonObject{ { "Value", output } } } } }, + { "output_schema", output } } ); + definition.nodes.push_back( { .name = "gather", .taskId = "t.gather" } ); + definition.inputs.insert( "gather", QJsonObject() ); + return RimWorkflowDefinitionTools::normalize( definition ); +} + +QStringList taskIssues( const RimWorkflowDefinition& definition, const QString& task ) +{ + std::vector issues; + for ( const auto& issue : RimWorkflowDefinitionTools::validate( definition ) ) + { + if ( issue.task == task ) issues.push_back( issue ); + } + return issueMessages( issues ); +} +} // namespace + +//-------------------------------------------------------------------------------------------------- +TEST( RimWorkflowDefinitionEdit, collectIntoList ) +{ + using Collect = RimWorkflowDefinitionEdge::Collect; + auto definition = collectDefinition(); + + definition = + edited( RimWorkflowDefinitionTools::connect( definition, { .from = "a", .output = "value", .to = "sum_list", .input = "values" } ) ); + definition = + edited( RimWorkflowDefinitionTools::connect( definition, { .from = "b", .output = "value", .to = "sum_list", .input = "values" } ) ); + ASSERT_EQ( 2u, definition.incomingEdges( "sum_list" ).size() ); + EXPECT_EQ( Collect::List, definition.incomingEdges( "sum_list" )[0].collect ); + EXPECT_EQ( "a", definition.incomingEdges( "sum_list" )[0].from ); + EXPECT_TRUE( definition.findNode( "sum_list" )->configFields.isEmpty() ); + EXPECT_TRUE( taskIssues( definition, "sum_list" ).isEmpty() ) << taskIssues( definition, "sum_list" ).join( "\n" ).toStdString(); + + // Collecting the same output twice is rejected + EXPECT_FALSE( RimWorkflowDefinitionTools::connect( definition, { .from = "a", .output = "value", .to = "sum_list", .input = "values" } ) ); + + // Reorder the members + const RimWorkflowDefinitionEdge second{ .from = "b", .output = "value", .to = "sum_list", .input = "values" }; + definition = edited( RimWorkflowDefinitionTools::moveCollectMember( definition, second, -1 ) ); + EXPECT_EQ( "b", definition.incomingEdges( "sum_list" )[0].from ); + EXPECT_FALSE( RimWorkflowDefinitionTools::moveCollectMember( definition, second, -1 ) ); + + // Index labels in the graph + const QJsonArray edges = RimWorkflowDefinitionTools::graphFromDefinition( definition, true ).value( "edges" ).toArray(); + QStringList labels; + for ( const QJsonValue& edge : edges ) + labels.append( edge.toObject().value( "from" ).toString() + edge.toObject().value( "label" ).toString() ); + EXPECT_TRUE( labels.contains( "b[0]" ) ); + EXPECT_TRUE( labels.contains( "a[1]" ) ); + + // Disconnecting ignores how the member is collected + definition = edited( + RimWorkflowDefinitionTools::disconnect( definition, { .from = "a", .output = "value", .to = "sum_list", .input = "values" } ) ); + EXPECT_EQ( 1u, definition.incomingEdges( "sum_list" ).size() ); + + // JSON round trip keeps the collected edges + const auto roundTrip = RimWorkflowDefinitionTools::fromJson( RimWorkflowDefinitionTools::toJson( definition ) ); + ASSERT_TRUE( roundTrip.has_value() ); + EXPECT_EQ( definition.edges, roundTrip->edges ); +} + +//-------------------------------------------------------------------------------------------------- +TEST( RimWorkflowDefinitionEdit, collectIntoDict ) +{ + using Collect = RimWorkflowDefinitionEdge::Collect; + auto definition = collectDefinition(); + + definition = + edited( RimWorkflowDefinitionTools::connect( definition, { .from = "a", .output = "value", .to = "sum_dict", .input = "values" } ) ); + definition = + edited( RimWorkflowDefinitionTools::connect( definition, { .from = "b", .output = "value", .to = "sum_dict", .input = "values" } ) ); + const auto incoming = definition.incomingEdges( "sum_dict" ); + ASSERT_EQ( 2u, incoming.size() ); + EXPECT_EQ( Collect::Dict, incoming[0].collect ); + EXPECT_EQ( "a_value", incoming[0].key ); + EXPECT_EQ( "b_value", incoming[1].key ); + + const RimWorkflowDefinitionEdge first{ .from = "a", .output = "value", .to = "sum_dict", .input = "values" }; + EXPECT_FALSE( RimWorkflowDefinitionTools::setCollectKey( definition, first, "b_value" ) ); + EXPECT_FALSE( RimWorkflowDefinitionTools::setCollectKey( definition, first, "" ) ); + EXPECT_FALSE( RimWorkflowDefinitionTools::setCollectKey( definition, first, " b_value " ) ); + definition = edited( RimWorkflowDefinitionTools::setCollectKey( definition, first, " low " ) ); + EXPECT_EQ( "low", definition.incomingEdges( "sum_dict" )[0].key ); + EXPECT_TRUE( taskIssues( definition, "sum_dict" ).isEmpty() ) << taskIssues( definition, "sum_dict" ).join( "\n" ).toStdString(); + + const auto roundTrip = RimWorkflowDefinitionTools::fromJson( RimWorkflowDefinitionTools::toJson( definition ) ); + ASSERT_TRUE( roundTrip.has_value() ); + EXPECT_EQ( definition.edges, roundTrip->edges ); + + // Duplicate keys are reported + definition.edges[1].key = "low"; + EXPECT_FALSE( taskIssues( definition, "sum_dict" ).isEmpty() ); +} + +//-------------------------------------------------------------------------------------------------- +TEST( RimWorkflowDefinitionEdit, mapTask ) +{ + auto definition = collectDefinition(); + + // The key input must accept a string + EXPECT_FALSE( + RimWorkflowDefinitionTools::setTaskMap( definition, "a", RimWorkflowTaskMap{ .over = "items", .keyAs = "factor", .valueAs = "name" } ) ); + EXPECT_FALSE( + RimWorkflowDefinitionTools::setTaskMap( definition, "a", RimWorkflowTaskMap{ .over = "name", .keyAs = "name", .valueAs = "factor" } ) ); + EXPECT_FALSE( + RimWorkflowDefinitionTools::setTaskMap( definition, "a", RimWorkflowTaskMap{ .over = "items", .keyAs = "name", .valueAs = "name" } ) ); + + const RimWorkflowTaskMap map{ .over = "items", .keyAs = "name", .valueAs = "factor" }; + definition = edited( RimWorkflowDefinitionTools::setTaskMap( definition, "a", map ) ); + ASSERT_TRUE( definition.findNode( "a" )->map.has_value() ); + EXPECT_TRUE( definition.findNode( "a" )->configFields.isEmpty() ); + + // The mapped output is a dict[str, Value]: it feeds a dict[str, Value] input directly + const QJsonObject mappedType = definition.taskTypeForNode( "a" ); + EXPECT_EQ( "dict[str, Value]", RimWorkflowPortCompatibility::outputPorts( mappedType ).front().typeName ); + EXPECT_FALSE( RimWorkflowPortCompatibility::canConnect( definition, "a", "", "sum_dict", "values" ) ); + EXPECT_FALSE( RimWorkflowPortCompatibility::canConnect( definition, "a", "", "sum_list", "values" ) ); + definition = edited( RimWorkflowDefinitionTools::connect( definition, { .from = "a", .to = "gather", .input = "results" } ) ); + EXPECT_FALSE( definition.incomingEdges( "gather" ).front().isCollected() ); + // A mapped task takes its inputs field by field + EXPECT_FALSE( RimWorkflowPortCompatibility::canConnect( definition, "b", "", "a", "" ) ); + + // The mapping is a config value shown as an input port + const QJsonObject task = taskByName( RimWorkflowDefinitionTools::graphFromDefinition( definition, true ), "a" ); + EXPECT_EQ( "items", task.value( "map" ).toObject().value( "over" ).toString() ); + EXPECT_EQ( "dict[str, float]", task.value( "input_types" ).toObject().value( "items" ).toString() ); + bool hasOverField = false; + for ( const QJsonValue& field : task.value( "config_fields" ).toArray() ) + { + if ( field.toObject().value( "name" ).toString() == "items" ) + { + hasOverField = field.toObject().value( "map_over" ).toBool(); + EXPECT_EQ( "number", field.toObject().value( "value_type" ).toString() ); + } + } + EXPECT_TRUE( hasOverField ); + + // JSON round trip + const auto roundTrip = RimWorkflowDefinitionTools::fromJson( RimWorkflowDefinitionTools::toJson( definition ) ); + ASSERT_TRUE( roundTrip.has_value() ); + EXPECT_EQ( definition, roundTrip.value() ); + + // Editing the map keeps the items, also when they are renamed + const QJsonObject items{ { "x", 1.0 } }; + definition.inputs.insert( "a", QJsonObject{ { "items", items } } ); + RimWorkflowTaskMap collectAll = map; + collectAll.errorMode = "collect_all"; + definition = edited( RimWorkflowDefinitionTools::setTaskMap( definition, "a", collectAll ) ); + EXPECT_EQ( items, definition.inputs.value( "a" ).toObject().value( "items" ).toObject() ); + RimWorkflowTaskMap renamed = collectAll; + renamed.over = "cases"; + definition = edited( RimWorkflowDefinitionTools::setTaskMap( definition, "a", renamed ) ); + EXPECT_FALSE( definition.inputs.value( "a" ).toObject().contains( "items" ) ); + EXPECT_EQ( items, definition.inputs.value( "a" ).toObject().value( "cases" ).toObject() ); + + // Removing the map restores the inputs and drops the mapping value + definition.inputs.insert( "a", QJsonObject{ { "cases", items } } ); + definition = edited( RimWorkflowDefinitionTools::setTaskMap( definition, "a", std::nullopt ) ); + EXPECT_FALSE( definition.findNode( "a" )->map.has_value() ); + EXPECT_EQ( QStringList( { "name", "factor" } ), definition.findNode( "a" )->configFields ); + EXPECT_FALSE( definition.inputs.value( "a" ).toObject().contains( "cases" ) ); +} diff --git a/ApplicationLibCode/UnitTests/RimWorkflowDescribeTools-Test.cpp b/ApplicationLibCode/UnitTests/RimWorkflowDescribeTools-Test.cpp index 39be32dbee..0addc96d8b 100644 --- a/ApplicationLibCode/UnitTests/RimWorkflowDescribeTools-Test.cpp +++ b/ApplicationLibCode/UnitTests/RimWorkflowDescribeTools-Test.cpp @@ -82,6 +82,8 @@ TEST( RimWorkflowDescribeTools, configFieldTypesAndValues ) EXPECT_EQ( "Person to greet", name.value( "description" ).toString() ); EXPECT_FALSE( name.value( "required" ).toBool() ); EXPECT_EQ( "world", fieldByName( greetB, "name" ).value( "default" ).toString() ); + EXPECT_EQ( "str", greetA.value( "input_types" ).toObject().value( "name" ).toString() ); + EXPECT_EQ( "int", greetA.value( "input_types" ).toObject().value( "times" ).toString() ); const QJsonObject times = fieldByName( greetA, "times" ); EXPECT_EQ( "integer", times.value( "type" ).toString() ); diff --git a/ApplicationLibCode/UnitTests/RimWorkflowJobSync-Test.cpp b/ApplicationLibCode/UnitTests/RimWorkflowJobSync-Test.cpp new file mode 100644 index 0000000000..4164d522f1 --- /dev/null +++ b/ApplicationLibCode/UnitTests/RimWorkflowJobSync-Test.cpp @@ -0,0 +1,338 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#include "gtest/gtest.h" + +#include "RiaTestDataDirectory.h" +#include "RimWorkflow.h" +#include "RimWorkflowDefinitionTools.h" +#include "RimWorkflowHelperProcess.h" +#include "RimWorkflowJob.h" +#include "RimWorkflowTaskInput.h" +#include "RimWorkflowValidationTools.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +RimWorkflowDefinition loadDataDefinition() +{ + QFile file( QString( "%1/RimWorkflowDefinition/load_data.json" ).arg( TEST_DATA_DIR ) ); + EXPECT_TRUE( file.open( QIODevice::ReadOnly ) ); + auto definition = RimWorkflowDefinitionTools::fromJson( QJsonDocument::fromJson( file.readAll() ).object() ); + EXPECT_TRUE( definition.has_value() ); + return definition.value_or( RimWorkflowDefinition() ); +} + +QJsonArray graphTasks( const RimWorkflowDefinition& definition ) +{ + return RimWorkflowDefinitionTools::graphFromDefinition( definition, true ).value( "tasks" ).toArray(); +} + +QString pathValue( const RimWorkflowJob& job, const QString& taskName ) +{ + return job.inputValues().value( taskName ).toObject().value( "path" ).toString(); +} +} // namespace + +//-------------------------------------------------------------------------------------------------- +TEST( RimWorkflowJobSync, oneTaskInputPerTask ) +{ + const auto definition = loadDataDefinition(); + RimWorkflowJob job; + job.syncTaskInputs( graphTasks( definition ) ); + + ASSERT_EQ( 4u, job.taskInputs().size() ); + EXPECT_EQ( "connect_to_resinsight", job.taskInputs()[0]->taskName() ); + EXPECT_EQ( 0u, job.taskInputs()[0]->count() ); + EXPECT_EQ( "resinsight.load_model", job.taskInput( "load_model" )->taskType() ); + EXPECT_EQ( "/path/to/grid.roff", pathValue( job, "load_model" ) ); + + // Tasks without config fields are not written + EXPECT_FALSE( job.inputValues().contains( "connect_to_resinsight" ) ); + EXPECT_EQ( QJsonArray( { "/path/to/property.roffasc" } ), + job.literalInputValues().value( "import_grid_property" ).toObject().value( "paths" ).toArray() ); + + QTemporaryDir directory; + const QString path = job.writeInputYaml( directory.filePath( "input.yaml" ) ); + QFile file( path ); + ASSERT_TRUE( file.open( QIODevice::ReadOnly ) ); + EXPECT_EQ( job.inputValues(), QJsonDocument::fromJson( file.readAll() ).object() ); +} + +//-------------------------------------------------------------------------------------------------- +TEST( RimWorkflowJobSync, valuesFollowRenamesAndRemovals ) +{ + auto definition = loadDataDefinition(); + RimWorkflowJob job; + job.syncTaskInputs( graphTasks( definition ) ); + RimWorkflowTaskInput* loadModel = job.taskInput( "load_model" ); + + // The binding object and its value are kept when the task is renamed + auto renamed = RimWorkflowDefinitionTools::renameTask( definition, "load_model", "load_grid" ); + ASSERT_TRUE( renamed.has_value() ); + renamed->inputs["load_grid"] = QJsonObject(); + job.syncTaskInputs( graphTasks( renamed.value() ), { { "load_model", "load_grid" } } ); + EXPECT_EQ( loadModel, job.taskInput( "load_grid" ) ); + EXPECT_EQ( "/path/to/grid.roff", pathValue( job, "load_grid" ) ); + + // Removing the task keeps its values aside, so undo brings them back + auto removed = RimWorkflowDefinitionTools::removeTask( renamed.value(), "load_grid" ); + ASSERT_TRUE( removed.has_value() ); + job.syncTaskInputs( graphTasks( removed.value() ) ); + EXPECT_EQ( nullptr, job.taskInput( "load_grid" ) ); + + job.syncTaskInputs( graphTasks( renamed.value() ) ); + EXPECT_EQ( "/path/to/grid.roff", pathValue( job, "load_grid" ) ); + + // And through a rename back + job.syncTaskInputs( graphTasks( removed.value() ) ); + auto restored = renamed.value(); + ASSERT_TRUE( restored.findNode( "load_grid" ) ); + restored.findNode( "load_grid" )->name = "load_model"; + restored.inputs.remove( "load_grid" ); + for ( auto& edge : restored.edges ) + { + if ( edge.from == "load_grid" ) edge.from = "load_model"; + } + job.syncTaskInputs( graphTasks( removed.value() ), { { "load_grid", "load_model" } } ); + job.syncTaskInputs( graphTasks( restored ) ); + EXPECT_EQ( "/path/to/grid.roff", pathValue( job, "load_model" ) ); +} + +//-------------------------------------------------------------------------------------------------- +TEST( RimWorkflowEditing, copyPythonModules ) +{ + QTemporaryDir directory; + ASSERT_TRUE( directory.isValid() ); + const QDir root( directory.path() ); + auto write = [&root]( const QString& path, const QByteArray& content ) + { + QDir().mkpath( QFileInfo( root.absoluteFilePath( path ) ).absolutePath() ); + QFile file( root.absoluteFilePath( path ) ); + return file.open( QIODevice::WriteOnly ) && file.write( content ) == content.size(); + }; + ASSERT_TRUE( write( "source/tasks.py", "new" ) ); + ASSERT_TRUE( write( "source/workflow.yaml", "" ) ); + ASSERT_TRUE( write( "source/helpers/__init__.py", "" ) ); + ASSERT_TRUE( write( "source/helpers/math.py", "" ) ); + ASSERT_TRUE( write( "source/helpers/__pycache__/math.pyc", "" ) ); + ASSERT_TRUE( write( "source/data/values.py", "" ) ); + ASSERT_TRUE( write( "target/tasks.py", "existing" ) ); + + ASSERT_TRUE( RimWorkflow::copyPythonModules( root.absoluteFilePath( "source" ), root.absoluteFilePath( "target" ) ).has_value() ); + EXPECT_TRUE( root.exists( "target/helpers/__init__.py" ) ); + EXPECT_TRUE( root.exists( "target/helpers/math.py" ) ); + EXPECT_FALSE( root.exists( "target/helpers/__pycache__" ) ); + EXPECT_FALSE( root.exists( "target/data" ) ); + EXPECT_FALSE( root.exists( "target/workflow.yaml" ) ); + + QFile existing( root.absoluteFilePath( "target/tasks.py" ) ); + ASSERT_TRUE( existing.open( QIODevice::ReadOnly ) ); + EXPECT_EQ( "existing", existing.readAll() ); + + // A target that cannot be created is reported + ASSERT_TRUE( write( "blocked", "" ) ); + EXPECT_FALSE( RimWorkflow::copyPythonModules( root.absoluteFilePath( "source" ), root.absoluteFilePath( "blocked/target" ) ).has_value() ); +} + +//-------------------------------------------------------------------------------------------------- +TEST( RimWorkflowEditing, undoRedoAndDirtyState ) +{ + RimWorkflow workflow; + workflow.setUnsavedDefinition( loadDataDefinition() ); + EXPECT_TRUE( workflow.isEditable() ); + EXPECT_TRUE( workflow.isDirty() ); + EXPECT_FALSE( workflow.canUndo() ); + ASSERT_EQ( 1u, workflow.jobs().size() ); + RimWorkflowJob* job = workflow.jobs().front(); + EXPECT_EQ( "/path/to/grid.roff", pathValue( *job, "load_model" ) ); + + const int revision = workflow.revision(); + auto result = workflow.applyEdit( RimWorkflowDefinitionTools::renameTask( workflow.definition(), "load_model", "load_grid" ), + { { "load_model", "load_grid" } } ); + ASSERT_TRUE( result.has_value() ); + EXPECT_GT( workflow.revision(), revision ); + EXPECT_TRUE( workflow.canUndo() ); + EXPECT_EQ( "/path/to/grid.roff", pathValue( *job, "load_grid" ) ); + EXPECT_TRUE( workflow.uiName().endsWith( " *" ) ); + + using Renames = QMap; + EXPECT_EQ( ( Renames{ { "load_grid", "load_model" } } ), workflow.undoRenames() ); + EXPECT_TRUE( workflow.redoRenames().isEmpty() ); + + workflow.undo(); + EXPECT_EQ( ( Renames{ { "load_model", "load_grid" } } ), workflow.redoRenames() ); + EXPECT_TRUE( workflow.definition().findNode( "load_model" ) ); + EXPECT_EQ( "/path/to/grid.roff", pathValue( *job, "load_model" ) ); + EXPECT_TRUE( workflow.canRedo() ); + + workflow.redo(); + EXPECT_TRUE( workflow.definition().findNode( "load_grid" ) ); + EXPECT_FALSE( workflow.canRedo() ); + + // Failed edits change nothing + const auto before = workflow.definition(); + EXPECT_FALSE( workflow.applyEdit( RimWorkflowDefinitionTools::renameTask( before, "load_grid", "bad name" ) ).has_value() ); + EXPECT_EQ( before, workflow.definition() ); + + // Read-only workflows cannot be edited + RimWorkflow readOnly; + auto readOnlyDefinition = loadDataDefinition(); + readOnlyDefinition.editable = false; + readOnly.loadFromRegistered( QJsonObject{ { "id", "test.workflow" }, + { "name", "workflow" }, + { "definition", RimWorkflowDefinitionTools::toJson( readOnlyDefinition ) } } ); + EXPECT_FALSE( readOnly.isEditable() ); + EXPECT_FALSE( readOnly.isDirty() ); + EXPECT_FALSE( readOnly.applyEdit( RimWorkflowDefinitionTools::setResultTask( readOnly.definition(), "" ) ).has_value() ); + auto runSource = readOnly.prepareRunSource( {} ); + ASSERT_TRUE( runSource.has_value() ); + EXPECT_EQ( QStringList( { "run", "--registered", "test.workflow" } ), runSource->baseArgs ); +} + +//-------------------------------------------------------------------------------------------------- +TEST( RimWorkflowEditing, editabilityOfInstalledAndDuplicatedWorkflows ) +{ + // An installed workflow is read-only even when its definition is editable, + // but keeps the editable flag so it can be duplicated + RimWorkflow installed; + installed.loadFromRegistered( QJsonObject{ { "id", "test.workflow" }, + { "name", "workflow" }, + { "definition", RimWorkflowDefinitionTools::toJson( loadDataDefinition() ) } } ); + EXPECT_FALSE( installed.isEditable() ); + EXPECT_TRUE( installed.definition().editable ); + EXPECT_FALSE( installed.definition().readOnlyReasons.isEmpty() ); + + RimWorkflow duplicate; + duplicate.setUnsavedDefinition( installed.definition() ); + EXPECT_TRUE( duplicate.isEditable() ); + EXPECT_TRUE( duplicate.definition().readOnlyReasons.isEmpty() ); + + // Unsupported structure (e.g. nested workflows) keeps a duplicate read-only + auto nested = loadDataDefinition(); + nested.nodes.front().extra = QJsonObject{ { "workflow", "other" } }; + const QStringList structural = RimWorkflowDefinitionTools::structuralReadOnlyReasons( nested ); + ASSERT_EQ( 1, structural.size() ); + EXPECT_TRUE( structural.front().contains( "workflow" ) ); + + RimWorkflow nestedWorkflow; + nestedWorkflow.setUnsavedDefinition( nested ); + EXPECT_FALSE( nestedWorkflow.isEditable() ); + EXPECT_EQ( structural, nestedWorkflow.definition().readOnlyReasons ); +} + +//-------------------------------------------------------------------------------------------------- +TEST( RimWorkflowValidationTools, issuesFromHelperResult ) +{ + const QStringList nodes{ "load_model", "load_model_2", "import" }; + + const QJsonObject describeError{ { "message", "Missing config" }, + { "task", "load_model" }, + { "issues", QJsonArray{ QJsonObject{ { "field", "path" }, { "code", "missing" } } } } }; + auto result = + RimWorkflowValidationTools::issuesFromHelperResult( QJsonObject{ { "status", "ok" }, { "describe_error", describeError } }, nodes ); + EXPECT_FALSE( result.describeSucceeded ); + ASSERT_EQ( 1u, result.issues.size() ); + EXPECT_EQ( "load_model", result.issues[0].task ); + EXPECT_EQ( "path", result.issues[0].field ); + EXPECT_EQ( RimWorkflowIssue::Severity::Error, result.issues[0].severity ); + + // The task is found from a quoted name in the message, preferring the longest match + result = + RimWorkflowValidationTools::issuesFromHelperResult( QJsonObject{ { "status", "invalid" }, + { "error", + QJsonObject{ { "message", "Task 'load_model_2' has a bad input" } } } }, + nodes ); + ASSERT_EQ( 1u, result.issues.size() ); + EXPECT_EQ( "load_model_2", result.issues[0].task ); + + // Unknown tasks go to the workflow + result = RimWorkflowValidationTools::issuesFromHelperResult( QJsonObject{ { "status", "invalid" }, + { "error", + QJsonObject{ { "message", "Broken" }, { "task", "other" } } } }, + nodes ); + ASSERT_EQ( 1u, result.issues.size() ); + EXPECT_TRUE( result.issues[0].task.isEmpty() ); + + // Missing config values are warnings + const QJsonObject describe{ + { "tasks", QJsonArray{ QJsonObject{ { "name", "import" }, { "missing_config_fields", QJsonArray{ "paths" } } } } } }; + result = RimWorkflowValidationTools::issuesFromHelperResult( QJsonObject{ { "status", "ok" }, { "describe", describe } }, nodes ); + EXPECT_TRUE( result.describeSucceeded ); + ASSERT_EQ( 1u, result.issues.size() ); + EXPECT_EQ( RimWorkflowIssue::Severity::Warning, result.issues[0].severity ); + EXPECT_EQ( "paths", result.issues[0].field ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +TEST( RimWorkflowHelperProcess, invalidResultKeepsTaskAndField ) +{ + const QByteArray invalid = R"({"status": "invalid", "error": {"message": "Bad value", "task": "load_model", "field": "path"}})"; + + const auto result = RimWorkflowHelperProcess::invalidResult( invalid + "\n" ); + ASSERT_TRUE( result.has_value() ); + const auto validation = RimWorkflowValidationTools::issuesFromHelperResult( *result, { "load_model" } ); + EXPECT_FALSE( validation.describeSucceeded ); + ASSERT_EQ( 1u, validation.issues.size() ); + EXPECT_EQ( "load_model", validation.issues[0].task ); + EXPECT_EQ( "path", validation.issues[0].field ); + + // parseOutput() reduces the same output to a message + const auto parsed = RimWorkflowHelperProcess::parseOutput( 2, invalid, {} ); + ASSERT_FALSE( parsed.has_value() ); + EXPECT_TRUE( parsed.error().startsWith( "Bad value" ) ); + + EXPECT_FALSE( RimWorkflowHelperProcess::invalidResult( R"({"status": "ok"})" ).has_value() ); + EXPECT_FALSE( RimWorkflowHelperProcess::invalidResult( "Traceback ..." ).has_value() ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +TEST( RimWorkflowValidationTools, issuesWithJobValues ) +{ + const QJsonObject missingCase{ { "task", "select" }, + { "field", "case" }, + { "severity", "warning" }, + { "message", RimWorkflowValidationTools::missingValueMessage( "case" ) } }; + const QJsonObject otherIssue{ { "task", "select" }, { "field", "" }, { "severity", "error" }, { "message", "Broken" } }; + + // A value in the job removes the stale warning from describe + QJsonArray issues = RimWorkflowValidationTools::issuesWithJobValues( "select", { missingCase, otherIssue }, { { "case", true } } ); + ASSERT_EQ( 1, issues.size() ); + EXPECT_EQ( "Broken", issues[0].toObject().value( "message" ).toString() ); + + // A missing value in the job gives one warning, even if describe also reported it + issues = RimWorkflowValidationTools::issuesWithJobValues( "select", { missingCase }, { { "case", false } } ); + ASSERT_EQ( 1, issues.size() ); + EXPECT_EQ( "case", issues[0].toObject().value( "field" ).toString() ); + EXPECT_EQ( "warning", issues[0].toObject().value( "severity" ).toString() ); + + // Fields the job does not bind keep the warnings from describe + issues = RimWorkflowValidationTools::issuesWithJobValues( "select", { missingCase }, {} ); + EXPECT_EQ( 1, issues.size() ); +} diff --git a/ApplicationLibCode/UnitTests/RimWorkflowVec3Binding-Test.cpp b/ApplicationLibCode/UnitTests/RimWorkflowVec3Binding-Test.cpp index 1c678f4ca8..af4c6372a8 100644 --- a/ApplicationLibCode/UnitTests/RimWorkflowVec3Binding-Test.cpp +++ b/ApplicationLibCode/UnitTests/RimWorkflowVec3Binding-Test.cpp @@ -18,16 +18,17 @@ TEST( RimWorkflowVec3Binding, AppliesObjectDefault ) { "default", QJsonObject{ { "x", 457196.0 }, { "y", 7322270.0 }, { "z", 2742.0 } } } }; binding.applySchema( schema ); - EXPECT_EQ( "{x: 457196, y: 7322270, z: 2742}", binding.toYamlValue() ); + const QJsonObject expected{ { "x", 457196.0 }, { "y", 7322270.0 }, { "z", 2742.0 } }; + EXPECT_EQ( QJsonValue( expected ), binding.toJsonValue() ); } //-------------------------------------------------------------------------------------------------- -TEST( RimWorkflowVec3Binding, NoDefaultYieldsNullYaml ) +TEST( RimWorkflowVec3Binding, NoDefaultYieldsNull ) { RimWorkflowVec3Binding binding; const QJsonObject schema{ { "name", "reference_point" }, { "required", true } }; binding.applySchema( schema ); - EXPECT_EQ( "null", binding.toYamlValue() ); + EXPECT_TRUE( binding.toJsonValue().isNull() ); } diff --git a/ApplicationLibCode/UnitTests/RiuWorkflowGraphLayout-Test.cpp b/ApplicationLibCode/UnitTests/RiuWorkflowGraphLayout-Test.cpp new file mode 100644 index 0000000000..59d02811c2 --- /dev/null +++ b/ApplicationLibCode/UnitTests/RiuWorkflowGraphLayout-Test.cpp @@ -0,0 +1,87 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#include "gtest/gtest.h" + +#include "RiuWorkflowGraphLayout.h" + +using Edges = std::vector>; + +//-------------------------------------------------------------------------------------------------- +TEST( RiuWorkflowGraphLayout, diamond ) +{ + const auto layout = + RiuWorkflowGraphLayout::computeLayers( { "a", "b", "c", "d" }, Edges{ { "a", "b" }, { "a", "c" }, { "b", "d" }, { "c", "d" } } ); + + ASSERT_EQ( 3u, layout.layers.size() ); + EXPECT_EQ( QStringList( { "a" } ), layout.layers[0] ); + EXPECT_EQ( QStringList( { "b", "c" } ), layout.layers[1] ); + EXPECT_EQ( QStringList( { "d" } ), layout.layers[2] ); +} + +//-------------------------------------------------------------------------------------------------- +TEST( RiuWorkflowGraphLayout, nonTopologicalInput ) +{ + const auto layout = + RiuWorkflowGraphLayout::computeLayers( { "d", "c", "b", "a" }, Edges{ { "c", "d" }, { "b", "c" }, { "a", "b" }, { "a", "d" } } ); + + EXPECT_EQ( 0, layout.ranks.value( "a" ) ); + EXPECT_EQ( 1, layout.ranks.value( "b" ) ); + EXPECT_EQ( 2, layout.ranks.value( "c" ) ); + EXPECT_EQ( 3, layout.ranks.value( "d" ) ); +} + +//-------------------------------------------------------------------------------------------------- +TEST( RiuWorkflowGraphLayout, disconnectedNodes ) +{ + const auto layout = RiuWorkflowGraphLayout::computeLayers( { "z", "a", "m" }, Edges{ { "a", "unknown" } } ); + + ASSERT_EQ( 1u, layout.layers.size() ); + EXPECT_EQ( QStringList( { "a", "m", "z" } ), layout.layers[0] ); +} + +//-------------------------------------------------------------------------------------------------- +TEST( RiuWorkflowGraphLayout, cycle ) +{ + const auto layout = RiuWorkflowGraphLayout::computeLayers( { "start", "a", "b", "end" }, + Edges{ { "start", "a" }, { "a", "b" }, { "b", "a" }, { "b", "end" } } ); + + EXPECT_EQ( 4, layout.ranks.size() ); + EXPECT_EQ( 0, layout.ranks.value( "start" ) ); + EXPECT_LT( layout.ranks.value( "a" ), layout.ranks.value( "b" ) ); + EXPECT_LT( layout.ranks.value( "b" ), layout.ranks.value( "end" ) ); + + int nodeCount = 0; + for ( const auto& layer : layout.layers ) + nodeCount += layer.size(); + EXPECT_EQ( 4, nodeCount ); +} + +//-------------------------------------------------------------------------------------------------- +TEST( RiuWorkflowGraphLayout, deterministicOrder ) +{ + const Edges edges{ { "root", "x" }, { "root", "y" }, { "other", "w" }, { "x", "end" } }; + const auto first = RiuWorkflowGraphLayout::computeLayers( { "root", "other", "x", "y", "w", "end" }, edges ); + const auto second = RiuWorkflowGraphLayout::computeLayers( { "end", "w", "y", "x", "other", "root" }, edges ); + + EXPECT_EQ( first.layers, second.layers ); + ASSERT_EQ( 3u, first.layers.size() ); + EXPECT_EQ( QStringList( { "other", "root" } ), first.layers[0] ); + // Children of "other" (position 0) come before children of "root" (position 1) + EXPECT_EQ( QStringList( { "w", "x", "y" } ), first.layers[1] ); +} diff --git a/ApplicationLibCode/UnitTests/TestData/RimWorkflowDefinition/catalog.json b/ApplicationLibCode/UnitTests/TestData/RimWorkflowDefinition/catalog.json new file mode 100644 index 0000000000..14a76bf7e4 --- /dev/null +++ b/ApplicationLibCode/UnitTests/TestData/RimWorkflowDefinition/catalog.json @@ -0,0 +1 @@ +{"format": 1, "status": "ok", "taskmaestro_version": "0.3.0", "tasks": [{"id": "resinsight.add_perforation", "name": "add_perforation", "python_type": "taskmaestro_resinsight.add_perforation.AddPerforation", "description": "Add perforation events to a well path.", "timeout_seconds": null, "input_schema": {"$defs": {"RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}, "WellPath": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, "description": "Input for AddPerforation: RipsInstance + well from upstream, MD range from config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "well_path": {"$ref": "#/$defs/WellPath"}, "event_date": {"description": "Perforation event date", "format": "date", "title": "Event Date", "type": "string"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["resinsight", "well_path", "event_date", "start_md", "end_md"], "title": "AddPerforationInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.AddPerforationInput"], "x-ri-python-type": "taskmaestro_resinsight.models.AddPerforationInput"}, "output_schema": {"description": "Output of AddPerforation: perforation interval details.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["value", "start_md", "end_md"], "title": "PerforationOutput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.PerforationOutput", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.PerforationOutput"}}, {"id": "resinsight.close_project", "name": "close_project", "python_type": "taskmaestro_resinsight.close_project.CloseProject", "description": "Close the current project and pass through the ResInsight connection.", "timeout_seconds": null, "input_schema": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}, "output_schema": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, {"id": "resinsight.configure_grid_view", "name": "configure_grid_view", "python_type": "taskmaestro_resinsight.configure_grid_view.ConfigureGridView", "description": "Apply a cell result and time step to an existing grid view.", "timeout_seconds": null, "input_schema": {"$defs": {"GridView": {"description": "Grid view on a loaded Eclipse case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.View", "rips.generated.generated_classes.ViewWindow", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.View", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.View"}}, "required": ["value"], "title": "GridView", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridView", "taskmaestro.object_model.ObjectModel[View]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridView"}}, "description": "Cell result and time step to display in a grid view.", "properties": {"grid_view": {"$ref": "#/$defs/GridView"}, "result_type": {"enum": ["DYNAMIC_NATIVE", "STATIC_NATIVE", "SOURSIMRL", "GENERATED", "INPUT_PROPERTY", "FORMATION_NAMES", "ALLAN_DIAGRAMS", "FLOW_DIAGNOSTICS", "INJECTION_FLOODING"], "title": "Result Type", "type": "string"}, "result_variable": {"minLength": 1, "title": "Result Variable", "type": "string"}, "time_step": {"default": 0, "minimum": 0, "title": "Time Step", "type": "integer"}}, "required": ["grid_view", "result_type", "result_variable"], "title": "ConfigureGridViewInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.ConfigureGridViewInput"], "x-ri-python-type": "taskmaestro_resinsight.models.ConfigureGridViewInput"}, "output_schema": {"description": "Grid view on a loaded Eclipse case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.View", "rips.generated.generated_classes.ViewWindow", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.View", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.View"}}, "required": ["value"], "title": "GridView", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridView", "taskmaestro.object_model.ObjectModel[View]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridView"}}, {"id": "resinsight.connect", "name": "connect_to_resinsight", "python_type": "taskmaestro_resinsight.connect_to_resinsight.ConnectToResInsight", "description": "Connect to a running ResInsight instance.", "timeout_seconds": null, "input_schema": {"description": "Sentinel config for workflows where all root tasks use JobConfiguration.", "properties": {}, "title": "EmptyConfig", "type": "object", "x-ri-python-bases": ["taskmaestro.job.EmptyConfig"], "x-ri-python-type": "taskmaestro.job.EmptyConfig"}, "output_schema": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, {"id": "resinsight.create_grid_view", "name": "create_grid_view", "python_type": "taskmaestro_resinsight.create_grid_view.CreateGridView", "description": "Create a new grid view on the loaded Eclipse case.", "timeout_seconds": null, "input_schema": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "output_schema": {"description": "Grid view on a loaded Eclipse case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.View", "rips.generated.generated_classes.ViewWindow", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.View", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.View"}}, "required": ["value"], "title": "GridView", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridView", "taskmaestro.object_model.ObjectModel[View]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridView"}}, {"id": "resinsight.exit", "name": "exit_resinsight", "python_type": "taskmaestro_resinsight.exit_resinsight.ExitResInsight", "description": "Send the exit command to a ResInsight application instance.", "timeout_seconds": null, "input_schema": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}, "output_schema": {"description": "Confirmation that the ResInsight exit command was sent.", "properties": {"exited": {"title": "Exited", "type": "boolean"}}, "required": ["exited"], "title": "ExitResult", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.ExitResult"], "x-ri-python-type": "taskmaestro_resinsight.models.ExitResult"}}, {"id": "resinsight.export_completions", "name": "export_completions", "python_type": "taskmaestro_resinsight.export_completions.ExportCompletions", "description": "Fan-in: merge case and perforations, then export completions.\n\nUses inline Inputs/Outputs inner classes for the named-ports fan-in pattern.\nexport_path comes from config.", "timeout_seconds": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "PerforationOutput": {"description": "Output of AddPerforation: perforation interval details.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["value", "start_md", "end_md"], "title": "PerforationOutput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.PerforationOutput", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.PerforationOutput"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "grid_case": {"$ref": "#/$defs/GridCase"}, "perforation_1": {"$ref": "#/$defs/PerforationOutput"}, "perforation_2": {"$ref": "#/$defs/PerforationOutput"}, "event_date": {"description": "Perforation event date", "format": "date", "title": "Event Date", "type": "string"}, "export_path": {"description": "Path to write the completions file", "format": "path", "title": "Export Path", "type": "string"}}, "required": ["resinsight", "grid_case", "perforation_1", "perforation_2", "event_date", "export_path"], "title": "Inputs", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.export_completions.ExportCompletions.Inputs"], "x-ri-python-type": "taskmaestro_resinsight.export_completions.ExportCompletions.Inputs"}, "output_schema": {"properties": {"export_file": {"title": "Export File", "type": "string"}, "well_path_names": {"items": {"type": "string"}, "title": "Well Path Names", "type": "array"}}, "required": ["export_file", "well_path_names"], "title": "Outputs", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.export_completions.ExportCompletions.Outputs"], "x-ri-python-type": "taskmaestro_resinsight.export_completions.ExportCompletions.Outputs"}}, {"id": "resinsight.import_grid_property", "name": "import_grid_property", "python_type": "taskmaestro_resinsight.import_grid_property.ImportGridProperty", "description": "Import grid property files into a loaded grid case.", "timeout_seconds": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "description": "Input for ImportGridProperty: RipsInstance, case, and property paths.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "grid_case": {"$ref": "#/$defs/GridCase"}, "paths": {"description": "Paths to grid property files to load", "items": {"type": "string"}, "title": "Paths", "type": "array"}}, "required": ["resinsight", "grid_case", "paths"], "title": "ImportGridPropertyInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.ImportGridPropertyInput"], "x-ri-python-type": "taskmaestro_resinsight.models.ImportGridPropertyInput"}, "output_schema": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}}, {"id": "resinsight.launch", "name": "launch_resinsight", "python_type": "taskmaestro_resinsight.launch_resinsight.LaunchResInsight", "description": "Launch a new ResInsight instance.", "timeout_seconds": null, "input_schema": {"description": "Configuration for launching ResInsight.", "properties": {"resinsight_executable": {"default": "", "title": "Resinsight Executable", "type": "string"}, "console": {"default": true, "title": "Console", "type": "boolean"}}, "title": "LaunchResInsightInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.launch_resinsight.LaunchResInsightInput"], "x-ri-python-type": "taskmaestro_resinsight.launch_resinsight.LaunchResInsightInput"}, "output_schema": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, {"id": "resinsight.load_model", "name": "load_model", "python_type": "taskmaestro_resinsight.load_model.LoadModel", "description": "Open an Eclipse case from a file path and wrap it as a GridCase.", "timeout_seconds": null, "input_schema": {"$defs": {"RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "description": "Input for LoadModel: RipsInstance from upstream, path from config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "path": {"description": "Path to the .EGRID file to load", "title": "Path", "type": "string"}}, "required": ["resinsight", "path"], "title": "LoadModelInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.LoadModelInput"], "x-ri-python-type": "taskmaestro_resinsight.models.LoadModelInput"}, "output_schema": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}}, {"id": "resinsight.load_regular_surface", "name": "load_regular_surface", "python_type": "taskmaestro_resinsight.load_regular_surface.LoadRegularSurface", "description": "Import a regular surface file and wrap it as a RegularSurface.", "timeout_seconds": null, "input_schema": {"$defs": {"RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "description": "Input for LoadRegularSurface: RipsInstance + path from config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "path": {"description": "Path to the regular surface file to load", "title": "Path", "type": "string"}}, "required": ["resinsight", "path"], "title": "LoadRegularSurfaceInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.LoadRegularSurfaceInput"], "x-ri-python-type": "taskmaestro_resinsight.models.LoadRegularSurfaceInput"}, "output_schema": {"description": "Loaded regular surface file.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Surface", "rips.generated.generated_classes.SurfaceInterface", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Surface", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Surface"}}, "required": ["value"], "title": "RegularSurface", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RegularSurface", "taskmaestro.object_model.ObjectModel[Surface]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RegularSurface"}}, {"id": "resinsight.load_well_path", "name": "load_well_path", "python_type": "taskmaestro_resinsight.load_well_path.LoadWellPath", "description": "Import a well trajectory file (e.g. .dev) and wrap it as a WellPath.", "timeout_seconds": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "description": "Input for LoadWellPath: RipsInstance + grid_case from upstream, path from config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "grid_case": {"$ref": "#/$defs/GridCase"}, "path": {"description": "Path to the .dev well-trajectory file to load", "title": "Path", "type": "string"}}, "required": ["resinsight", "grid_case", "path"], "title": "LoadWellPathInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.LoadWellPathInput"], "x-ri-python-type": "taskmaestro_resinsight.models.LoadWellPathInput"}, "output_schema": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, {"id": "resinsight.open_project", "name": "open_project", "python_type": "taskmaestro_resinsight.open_project.OpenProject", "description": "Open a project file and pass through the ResInsight connection.", "timeout_seconds": null, "input_schema": {"$defs": {"RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "description": "Input for opening a ResInsight project file.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "path": {"description": "Path to the .rsp project file", "minLength": 1, "title": "Path", "type": "string"}}, "required": ["resinsight", "path"], "title": "OpenProjectInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.OpenProjectInput"], "x-ri-python-type": "taskmaestro_resinsight.models.OpenProjectInput"}, "output_schema": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, {"id": "resinsight.save_project", "name": "save_project", "python_type": "taskmaestro_resinsight.save_project.SaveProject", "description": "Save the current project and pass through the ResInsight connection.", "timeout_seconds": null, "input_schema": {"$defs": {"RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "description": "Input for saving the current ResInsight project.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "path": {"default": "", "description": "Destination .rsp path; empty saves to the current project file", "title": "Path", "type": "string"}}, "required": ["resinsight"], "title": "SaveProjectInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.SaveProjectInput"], "x-ri-python-type": "taskmaestro_resinsight.models.SaveProjectInput"}, "output_schema": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, {"id": "resinsight.select_eclipse_case", "name": "select_eclipse_case", "python_type": "taskmaestro_resinsight.select_eclipse_case.SelectEclipseCase", "description": "Pass an existing case through to downstream tasks.", "timeout_seconds": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "description": "Input for SelectEclipseCase: RipsInstance from upstream, case selected via config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "case": {"$ref": "#/$defs/GridCase", "description": "Case to use"}}, "required": ["resinsight", "case"], "title": "SelectEclipseCaseInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.SelectEclipseCaseInput"], "x-ri-python-type": "taskmaestro_resinsight.models.SelectEclipseCaseInput"}, "output_schema": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}}, {"id": "resinsight.select_well_path", "name": "select_well_path", "python_type": "taskmaestro_resinsight.select_well_path.SelectWellPath", "description": "Pass an existing well path through to downstream tasks.", "timeout_seconds": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}, "WellPath": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, "description": "Input for SelectWellPath: RipsInstance from upstream, well path selected via config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "grid_case": {"$ref": "#/$defs/GridCase"}, "well_path": {"$ref": "#/$defs/WellPath", "description": "Well path to use"}}, "required": ["resinsight", "grid_case", "well_path"], "title": "SelectWellPathInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.SelectWellPathInput"], "x-ri-python-type": "taskmaestro_resinsight.models.SelectWellPathInput"}, "output_schema": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}], "workflows": [{"id": "resinsight.completions", "name": "resinsight_completions", "definition": {"format": 1, "name": "resinsight_completions", "result_task": null, "editable": true, "readonly_reasons": [], "warnings": [], "source": {"workflow_yaml": null, "input_yaml": null, "registered_id": "resinsight.completions"}, "header_comment": "", "passthrough": {}, "nodes": [{"name": "connect_to_resinsight", "task": "resinsight.connect", "config_fields": []}, {"name": "select_eclipse_case", "task": "resinsight.select_eclipse_case", "config_fields": ["case"]}, {"name": "select_well_path_1", "task": "resinsight.select_well_path", "config_fields": ["well_path"]}, {"name": "add_perf_1", "task": "resinsight.add_perforation", "config_fields": ["end_md", "event_date", "start_md"]}, {"name": "select_well_path_2", "task": "resinsight.select_well_path", "config_fields": ["well_path"]}, {"name": "add_perf_2", "task": "resinsight.add_perforation", "config_fields": ["end_md", "event_date", "start_md"]}, {"name": "export_completions", "task": "resinsight.export_completions", "config_fields": ["event_date", "export_path"]}], "edges": [{"from": "connect_to_resinsight", "output": null, "to": "select_eclipse_case", "input": "resinsight"}, {"from": "connect_to_resinsight", "output": null, "to": "select_well_path_1", "input": "resinsight"}, {"from": "select_eclipse_case", "output": null, "to": "select_well_path_1", "input": "grid_case"}, {"from": "connect_to_resinsight", "output": null, "to": "add_perf_1", "input": "resinsight"}, {"from": "select_well_path_1", "output": null, "to": "add_perf_1", "input": "well_path"}, {"from": "connect_to_resinsight", "output": null, "to": "select_well_path_2", "input": "resinsight"}, {"from": "select_eclipse_case", "output": null, "to": "select_well_path_2", "input": "grid_case"}, {"from": "connect_to_resinsight", "output": null, "to": "add_perf_2", "input": "resinsight"}, {"from": "select_well_path_2", "output": null, "to": "add_perf_2", "input": "well_path"}, {"from": "connect_to_resinsight", "output": null, "to": "export_completions", "input": "resinsight"}, {"from": "select_eclipse_case", "output": null, "to": "export_completions", "input": "grid_case"}, {"from": "add_perf_1", "output": null, "to": "export_completions", "input": "perforation_1"}, {"from": "add_perf_2", "output": null, "to": "export_completions", "input": "perforation_2"}], "inputs": {"connect_to_resinsight": {}, "select_eclipse_case": {}, "select_well_path_1": {}, "add_perf_1": {}, "select_well_path_2": {}, "add_perf_2": {}, "export_completions": {}}, "task_types": {"resinsight.connect": {"id": "resinsight.connect", "name": "connect_to_resinsight", "python_type": "taskmaestro_resinsight.connect_to_resinsight.ConnectToResInsight", "description": "Connect to a running ResInsight instance.", "timeout_seconds": null, "input_schema": {"description": "Sentinel config for workflows where all root tasks use JobConfiguration.", "properties": {}, "title": "EmptyConfig", "type": "object", "x-ri-python-bases": ["taskmaestro.job.EmptyConfig"], "x-ri-python-type": "taskmaestro.job.EmptyConfig"}, "output_schema": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "resinsight.select_eclipse_case": {"id": "resinsight.select_eclipse_case", "name": "select_eclipse_case", "python_type": "taskmaestro_resinsight.select_eclipse_case.SelectEclipseCase", "description": "Pass an existing case through to downstream tasks.", "timeout_seconds": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "description": "Input for SelectEclipseCase: RipsInstance from upstream, case selected via config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "case": {"$ref": "#/$defs/GridCase", "description": "Case to use"}}, "required": ["resinsight", "case"], "title": "SelectEclipseCaseInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.SelectEclipseCaseInput"], "x-ri-python-type": "taskmaestro_resinsight.models.SelectEclipseCaseInput"}, "output_schema": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}}, "resinsight.select_well_path": {"id": "resinsight.select_well_path", "name": "select_well_path", "python_type": "taskmaestro_resinsight.select_well_path.SelectWellPath", "description": "Pass an existing well path through to downstream tasks.", "timeout_seconds": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}, "WellPath": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, "description": "Input for SelectWellPath: RipsInstance from upstream, well path selected via config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "grid_case": {"$ref": "#/$defs/GridCase"}, "well_path": {"$ref": "#/$defs/WellPath", "description": "Well path to use"}}, "required": ["resinsight", "grid_case", "well_path"], "title": "SelectWellPathInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.SelectWellPathInput"], "x-ri-python-type": "taskmaestro_resinsight.models.SelectWellPathInput"}, "output_schema": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, "resinsight.add_perforation": {"id": "resinsight.add_perforation", "name": "add_perforation", "python_type": "taskmaestro_resinsight.add_perforation.AddPerforation", "description": "Add perforation events to a well path.", "timeout_seconds": null, "input_schema": {"$defs": {"RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}, "WellPath": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, "description": "Input for AddPerforation: RipsInstance + well from upstream, MD range from config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "well_path": {"$ref": "#/$defs/WellPath"}, "event_date": {"description": "Perforation event date", "format": "date", "title": "Event Date", "type": "string"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["resinsight", "well_path", "event_date", "start_md", "end_md"], "title": "AddPerforationInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.AddPerforationInput"], "x-ri-python-type": "taskmaestro_resinsight.models.AddPerforationInput"}, "output_schema": {"description": "Output of AddPerforation: perforation interval details.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["value", "start_md", "end_md"], "title": "PerforationOutput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.PerforationOutput", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.PerforationOutput"}}, "resinsight.export_completions": {"id": "resinsight.export_completions", "name": "export_completions", "python_type": "taskmaestro_resinsight.export_completions.ExportCompletions", "description": "Fan-in: merge case and perforations, then export completions.\n\nUses inline Inputs/Outputs inner classes for the named-ports fan-in pattern.\nexport_path comes from config.", "timeout_seconds": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "PerforationOutput": {"description": "Output of AddPerforation: perforation interval details.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["value", "start_md", "end_md"], "title": "PerforationOutput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.PerforationOutput", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.PerforationOutput"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "grid_case": {"$ref": "#/$defs/GridCase"}, "perforation_1": {"$ref": "#/$defs/PerforationOutput"}, "perforation_2": {"$ref": "#/$defs/PerforationOutput"}, "event_date": {"description": "Perforation event date", "format": "date", "title": "Event Date", "type": "string"}, "export_path": {"description": "Path to write the completions file", "format": "path", "title": "Export Path", "type": "string"}}, "required": ["resinsight", "grid_case", "perforation_1", "perforation_2", "event_date", "export_path"], "title": "Inputs", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.export_completions.ExportCompletions.Inputs"], "x-ri-python-type": "taskmaestro_resinsight.export_completions.ExportCompletions.Inputs"}, "output_schema": {"properties": {"export_file": {"title": "Export File", "type": "string"}, "well_path_names": {"items": {"type": "string"}, "title": "Well Path Names", "type": "array"}}, "required": ["export_file", "well_path_names"], "title": "Outputs", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.export_completions.ExportCompletions.Outputs"], "x-ri-python-type": "taskmaestro_resinsight.export_completions.ExportCompletions.Outputs"}}}, "describe": {"workflow": "resinsight_completions", "result_task": "export_completions", "tasks": [{"name": "connect_to_resinsight", "python_type": "taskmaestro_resinsight.connect_to_resinsight.ConnectToResInsight", "depends_on": null, "config_fields": [], "provided_config_fields": null, "missing_config_fields": null, "config_values": null, "required_input_fields": [], "map": null, "input_schema": {"description": "Sentinel config for workflows where all root tasks use JobConfiguration.", "properties": {}, "title": "EmptyConfig", "type": "object", "x-ri-python-bases": ["taskmaestro.job.EmptyConfig"], "x-ri-python-type": "taskmaestro.job.EmptyConfig"}, "output_schema": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, {"name": "select_eclipse_case", "python_type": "taskmaestro_resinsight.select_eclipse_case.SelectEclipseCase", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}}, "config_fields": ["case"], "provided_config_fields": null, "missing_config_fields": null, "config_values": null, "required_input_fields": ["case", "resinsight"], "map": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "description": "Input for SelectEclipseCase: RipsInstance from upstream, case selected via config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "case": {"$ref": "#/$defs/GridCase", "description": "Case to use"}}, "required": ["resinsight", "case"], "title": "SelectEclipseCaseInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.SelectEclipseCaseInput"], "x-ri-python-type": "taskmaestro_resinsight.models.SelectEclipseCaseInput"}, "output_schema": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}}, {"name": "select_well_path_1", "python_type": "taskmaestro_resinsight.select_well_path.SelectWellPath", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}, "grid_case": {"task": "select_eclipse_case", "field": null}}, "config_fields": ["well_path"], "provided_config_fields": null, "missing_config_fields": null, "config_values": null, "required_input_fields": ["grid_case", "resinsight", "well_path"], "map": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}, "WellPath": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, "description": "Input for SelectWellPath: RipsInstance from upstream, well path selected via config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "grid_case": {"$ref": "#/$defs/GridCase"}, "well_path": {"$ref": "#/$defs/WellPath", "description": "Well path to use"}}, "required": ["resinsight", "grid_case", "well_path"], "title": "SelectWellPathInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.SelectWellPathInput"], "x-ri-python-type": "taskmaestro_resinsight.models.SelectWellPathInput"}, "output_schema": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, {"name": "add_perf_1", "python_type": "taskmaestro_resinsight.add_perforation.AddPerforation", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}, "well_path": {"task": "select_well_path_1", "field": null}}, "config_fields": ["end_md", "event_date", "start_md"], "provided_config_fields": null, "missing_config_fields": null, "config_values": null, "required_input_fields": ["end_md", "event_date", "resinsight", "start_md", "well_path"], "map": null, "input_schema": {"$defs": {"RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}, "WellPath": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, "description": "Input for AddPerforation: RipsInstance + well from upstream, MD range from config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "well_path": {"$ref": "#/$defs/WellPath"}, "event_date": {"description": "Perforation event date", "format": "date", "title": "Event Date", "type": "string"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["resinsight", "well_path", "event_date", "start_md", "end_md"], "title": "AddPerforationInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.AddPerforationInput"], "x-ri-python-type": "taskmaestro_resinsight.models.AddPerforationInput"}, "output_schema": {"description": "Output of AddPerforation: perforation interval details.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["value", "start_md", "end_md"], "title": "PerforationOutput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.PerforationOutput", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.PerforationOutput"}}, {"name": "select_well_path_2", "python_type": "taskmaestro_resinsight.select_well_path.SelectWellPath", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}, "grid_case": {"task": "select_eclipse_case", "field": null}}, "config_fields": ["well_path"], "provided_config_fields": null, "missing_config_fields": null, "config_values": null, "required_input_fields": ["grid_case", "resinsight", "well_path"], "map": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}, "WellPath": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, "description": "Input for SelectWellPath: RipsInstance from upstream, well path selected via config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "grid_case": {"$ref": "#/$defs/GridCase"}, "well_path": {"$ref": "#/$defs/WellPath", "description": "Well path to use"}}, "required": ["resinsight", "grid_case", "well_path"], "title": "SelectWellPathInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.SelectWellPathInput"], "x-ri-python-type": "taskmaestro_resinsight.models.SelectWellPathInput"}, "output_schema": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, {"name": "add_perf_2", "python_type": "taskmaestro_resinsight.add_perforation.AddPerforation", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}, "well_path": {"task": "select_well_path_2", "field": null}}, "config_fields": ["end_md", "event_date", "start_md"], "provided_config_fields": null, "missing_config_fields": null, "config_values": null, "required_input_fields": ["end_md", "event_date", "resinsight", "start_md", "well_path"], "map": null, "input_schema": {"$defs": {"RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}, "WellPath": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, "description": "Input for AddPerforation: RipsInstance + well from upstream, MD range from config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "well_path": {"$ref": "#/$defs/WellPath"}, "event_date": {"description": "Perforation event date", "format": "date", "title": "Event Date", "type": "string"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["resinsight", "well_path", "event_date", "start_md", "end_md"], "title": "AddPerforationInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.AddPerforationInput"], "x-ri-python-type": "taskmaestro_resinsight.models.AddPerforationInput"}, "output_schema": {"description": "Output of AddPerforation: perforation interval details.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["value", "start_md", "end_md"], "title": "PerforationOutput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.PerforationOutput", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.PerforationOutput"}}, {"name": "export_completions", "python_type": "taskmaestro_resinsight.export_completions.ExportCompletions", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}, "grid_case": {"task": "select_eclipse_case", "field": null}, "perforation_1": {"task": "add_perf_1", "field": null}, "perforation_2": {"task": "add_perf_2", "field": null}}, "config_fields": ["event_date", "export_path"], "provided_config_fields": null, "missing_config_fields": null, "config_values": null, "required_input_fields": ["event_date", "export_path", "grid_case", "perforation_1", "perforation_2", "resinsight"], "map": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "PerforationOutput": {"description": "Output of AddPerforation: perforation interval details.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["value", "start_md", "end_md"], "title": "PerforationOutput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.PerforationOutput", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.PerforationOutput"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "grid_case": {"$ref": "#/$defs/GridCase"}, "perforation_1": {"$ref": "#/$defs/PerforationOutput"}, "perforation_2": {"$ref": "#/$defs/PerforationOutput"}, "event_date": {"description": "Perforation event date", "format": "date", "title": "Event Date", "type": "string"}, "export_path": {"description": "Path to write the completions file", "format": "path", "title": "Export Path", "type": "string"}}, "required": ["resinsight", "grid_case", "perforation_1", "perforation_2", "event_date", "export_path"], "title": "Inputs", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.export_completions.ExportCompletions.Inputs"], "x-ri-python-type": "taskmaestro_resinsight.export_completions.ExportCompletions.Inputs"}, "output_schema": {"properties": {"export_file": {"title": "Export File", "type": "string"}, "well_path_names": {"items": {"type": "string"}, "title": "Well Path Names", "type": "array"}}, "required": ["export_file", "well_path_names"], "title": "Outputs", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.export_completions.ExportCompletions.Outputs"], "x-ri-python-type": "taskmaestro_resinsight.export_completions.ExportCompletions.Outputs"}}]}, "describe_error": null}, "describe": {"workflow": "resinsight_completions", "result_task": "export_completions", "tasks": [{"name": "connect_to_resinsight", "python_type": "taskmaestro_resinsight.connect_to_resinsight.ConnectToResInsight", "depends_on": null, "config_fields": [], "provided_config_fields": null, "missing_config_fields": null, "config_values": null, "required_input_fields": [], "map": null, "input_schema": {"description": "Sentinel config for workflows where all root tasks use JobConfiguration.", "properties": {}, "title": "EmptyConfig", "type": "object", "x-ri-python-bases": ["taskmaestro.job.EmptyConfig"], "x-ri-python-type": "taskmaestro.job.EmptyConfig"}, "output_schema": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, {"name": "select_eclipse_case", "python_type": "taskmaestro_resinsight.select_eclipse_case.SelectEclipseCase", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}}, "config_fields": ["case"], "provided_config_fields": null, "missing_config_fields": null, "config_values": null, "required_input_fields": ["case", "resinsight"], "map": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "description": "Input for SelectEclipseCase: RipsInstance from upstream, case selected via config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "case": {"$ref": "#/$defs/GridCase", "description": "Case to use"}}, "required": ["resinsight", "case"], "title": "SelectEclipseCaseInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.SelectEclipseCaseInput"], "x-ri-python-type": "taskmaestro_resinsight.models.SelectEclipseCaseInput"}, "output_schema": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}}, {"name": "select_well_path_1", "python_type": "taskmaestro_resinsight.select_well_path.SelectWellPath", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}, "grid_case": {"task": "select_eclipse_case", "field": null}}, "config_fields": ["well_path"], "provided_config_fields": null, "missing_config_fields": null, "config_values": null, "required_input_fields": ["grid_case", "resinsight", "well_path"], "map": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}, "WellPath": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, "description": "Input for SelectWellPath: RipsInstance from upstream, well path selected via config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "grid_case": {"$ref": "#/$defs/GridCase"}, "well_path": {"$ref": "#/$defs/WellPath", "description": "Well path to use"}}, "required": ["resinsight", "grid_case", "well_path"], "title": "SelectWellPathInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.SelectWellPathInput"], "x-ri-python-type": "taskmaestro_resinsight.models.SelectWellPathInput"}, "output_schema": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, {"name": "add_perf_1", "python_type": "taskmaestro_resinsight.add_perforation.AddPerforation", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}, "well_path": {"task": "select_well_path_1", "field": null}}, "config_fields": ["end_md", "event_date", "start_md"], "provided_config_fields": null, "missing_config_fields": null, "config_values": null, "required_input_fields": ["end_md", "event_date", "resinsight", "start_md", "well_path"], "map": null, "input_schema": {"$defs": {"RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}, "WellPath": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, "description": "Input for AddPerforation: RipsInstance + well from upstream, MD range from config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "well_path": {"$ref": "#/$defs/WellPath"}, "event_date": {"description": "Perforation event date", "format": "date", "title": "Event Date", "type": "string"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["resinsight", "well_path", "event_date", "start_md", "end_md"], "title": "AddPerforationInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.AddPerforationInput"], "x-ri-python-type": "taskmaestro_resinsight.models.AddPerforationInput"}, "output_schema": {"description": "Output of AddPerforation: perforation interval details.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["value", "start_md", "end_md"], "title": "PerforationOutput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.PerforationOutput", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.PerforationOutput"}}, {"name": "select_well_path_2", "python_type": "taskmaestro_resinsight.select_well_path.SelectWellPath", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}, "grid_case": {"task": "select_eclipse_case", "field": null}}, "config_fields": ["well_path"], "provided_config_fields": null, "missing_config_fields": null, "config_values": null, "required_input_fields": ["grid_case", "resinsight", "well_path"], "map": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}, "WellPath": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, "description": "Input for SelectWellPath: RipsInstance from upstream, well path selected via config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "grid_case": {"$ref": "#/$defs/GridCase"}, "well_path": {"$ref": "#/$defs/WellPath", "description": "Well path to use"}}, "required": ["resinsight", "grid_case", "well_path"], "title": "SelectWellPathInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.SelectWellPathInput"], "x-ri-python-type": "taskmaestro_resinsight.models.SelectWellPathInput"}, "output_schema": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, {"name": "add_perf_2", "python_type": "taskmaestro_resinsight.add_perforation.AddPerforation", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}, "well_path": {"task": "select_well_path_2", "field": null}}, "config_fields": ["end_md", "event_date", "start_md"], "provided_config_fields": null, "missing_config_fields": null, "config_values": null, "required_input_fields": ["end_md", "event_date", "resinsight", "start_md", "well_path"], "map": null, "input_schema": {"$defs": {"RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}, "WellPath": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, "description": "Input for AddPerforation: RipsInstance + well from upstream, MD range from config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "well_path": {"$ref": "#/$defs/WellPath"}, "event_date": {"description": "Perforation event date", "format": "date", "title": "Event Date", "type": "string"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["resinsight", "well_path", "event_date", "start_md", "end_md"], "title": "AddPerforationInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.AddPerforationInput"], "x-ri-python-type": "taskmaestro_resinsight.models.AddPerforationInput"}, "output_schema": {"description": "Output of AddPerforation: perforation interval details.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["value", "start_md", "end_md"], "title": "PerforationOutput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.PerforationOutput", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.PerforationOutput"}}, {"name": "export_completions", "python_type": "taskmaestro_resinsight.export_completions.ExportCompletions", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}, "grid_case": {"task": "select_eclipse_case", "field": null}, "perforation_1": {"task": "add_perf_1", "field": null}, "perforation_2": {"task": "add_perf_2", "field": null}}, "config_fields": ["event_date", "export_path"], "provided_config_fields": null, "missing_config_fields": null, "config_values": null, "required_input_fields": ["event_date", "export_path", "grid_case", "perforation_1", "perforation_2", "resinsight"], "map": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "PerforationOutput": {"description": "Output of AddPerforation: perforation interval details.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["value", "start_md", "end_md"], "title": "PerforationOutput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.PerforationOutput", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.PerforationOutput"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "grid_case": {"$ref": "#/$defs/GridCase"}, "perforation_1": {"$ref": "#/$defs/PerforationOutput"}, "perforation_2": {"$ref": "#/$defs/PerforationOutput"}, "event_date": {"description": "Perforation event date", "format": "date", "title": "Event Date", "type": "string"}, "export_path": {"description": "Path to write the completions file", "format": "path", "title": "Export Path", "type": "string"}}, "required": ["resinsight", "grid_case", "perforation_1", "perforation_2", "event_date", "export_path"], "title": "Inputs", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.export_completions.ExportCompletions.Inputs"], "x-ri-python-type": "taskmaestro_resinsight.export_completions.ExportCompletions.Inputs"}, "output_schema": {"properties": {"export_file": {"title": "Export File", "type": "string"}, "well_path_names": {"items": {"type": "string"}, "title": "Well Path Names", "type": "array"}}, "required": ["export_file", "well_path_names"], "title": "Outputs", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.export_completions.ExportCompletions.Outputs"], "x-ri-python-type": "taskmaestro_resinsight.export_completions.ExportCompletions.Outputs"}}]}, "editable": true, "readonly_reasons": []}], "errors": []} diff --git a/ApplicationLibCode/UnitTests/TestData/RimWorkflowDefinition/completions.json b/ApplicationLibCode/UnitTests/TestData/RimWorkflowDefinition/completions.json new file mode 100644 index 0000000000..bf419907f9 --- /dev/null +++ b/ApplicationLibCode/UnitTests/TestData/RimWorkflowDefinition/completions.json @@ -0,0 +1 @@ +{"format": 1, "name": "resinsight_completions", "result_task": null, "editable": true, "readonly_reasons": [], "warnings": [], "source": {"workflow_yaml": "test_data/taskmaestro_workflows/resinsight_completions/workflow.yaml", "input_yaml": "test_data/taskmaestro_workflows/resinsight_completions/input.yaml", "registered_id": null}, "header_comment": "# ResInsight Completions Pipeline \u2014 YAML workflow configuration\n#\n# Tasks use stable entry-point identifiers published by taskmaestro-resinsight,\n# rather than Python module/class paths.\n#\n# DAG with fan-out / fan-in pattern:\n# ConnectToResInsight \u2192 SelectEclipseCase \u2192 [SelectWellPath x2] \u2192 [AddPerforation x2] \u2192 ExportCompletions", "passthrough": {"runner": {"hooks": [{"hook": "taskmaestro.hooks.logging.LoggingHook"}, {"hook": "taskmaestro.hooks.timing.TimingHook"}]}}, "nodes": [{"name": "connect_to_resinsight", "task": "resinsight.connect", "config_fields": []}, {"name": "select_eclipse_case", "task": "resinsight.select_eclipse_case", "config_fields": ["case"]}, {"name": "select_well_path_1", "task": "resinsight.select_well_path", "config_fields": ["well_path"]}, {"name": "select_well_path_2", "task": "resinsight.select_well_path", "config_fields": ["well_path"]}, {"name": "add_perf_1", "task": "resinsight.add_perforation", "config_fields": ["event_date", "start_md", "end_md"]}, {"name": "add_perf_2", "task": "resinsight.add_perforation", "config_fields": ["event_date", "start_md", "end_md"]}, {"name": "export_completions", "task": "resinsight.export_completions", "config_fields": ["event_date", "export_path"]}], "edges": [{"from": "connect_to_resinsight", "output": null, "to": "select_eclipse_case", "input": "resinsight"}, {"from": "connect_to_resinsight", "output": null, "to": "select_well_path_1", "input": "resinsight"}, {"from": "select_eclipse_case", "output": null, "to": "select_well_path_1", "input": "grid_case"}, {"from": "connect_to_resinsight", "output": null, "to": "select_well_path_2", "input": "resinsight"}, {"from": "select_eclipse_case", "output": null, "to": "select_well_path_2", "input": "grid_case"}, {"from": "connect_to_resinsight", "output": null, "to": "add_perf_1", "input": "resinsight"}, {"from": "select_well_path_1", "output": null, "to": "add_perf_1", "input": "well_path"}, {"from": "connect_to_resinsight", "output": null, "to": "add_perf_2", "input": "resinsight"}, {"from": "select_well_path_2", "output": null, "to": "add_perf_2", "input": "well_path"}, {"from": "connect_to_resinsight", "output": null, "to": "export_completions", "input": "resinsight"}, {"from": "select_eclipse_case", "output": null, "to": "export_completions", "input": "grid_case"}, {"from": "add_perf_1", "output": null, "to": "export_completions", "input": "perforation_1"}, {"from": "add_perf_2", "output": null, "to": "export_completions", "input": "perforation_2"}], "inputs": {"connect_to_resinsight": {}, "select_eclipse_case": {}, "select_well_path_1": {}, "select_well_path_2": {}, "add_perf_1": {"event_date": "2024-01-01", "start_md": 3000.0, "end_md": 3500.0}, "add_perf_2": {"event_date": "2024-02-01", "start_md": 2400.0, "end_md": 2600.0}, "export_completions": {"event_date": "2024-05-01", "export_path": "/tmp/completions.txt"}}, "task_types": {"resinsight.connect": {"id": "resinsight.connect", "name": "connect_to_resinsight", "python_type": "taskmaestro_resinsight.connect_to_resinsight.ConnectToResInsight", "description": "Connect to a running ResInsight instance.", "timeout_seconds": null, "input_schema": {"description": "Sentinel config for workflows where all root tasks use JobConfiguration.", "properties": {}, "title": "EmptyConfig", "type": "object", "x-ri-python-bases": ["taskmaestro.job.EmptyConfig"], "x-ri-python-type": "taskmaestro.job.EmptyConfig"}, "output_schema": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "resinsight.select_eclipse_case": {"id": "resinsight.select_eclipse_case", "name": "select_eclipse_case", "python_type": "taskmaestro_resinsight.select_eclipse_case.SelectEclipseCase", "description": "Pass an existing case through to downstream tasks.", "timeout_seconds": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "description": "Input for SelectEclipseCase: RipsInstance from upstream, case selected via config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "case": {"$ref": "#/$defs/GridCase", "description": "Case to use"}}, "required": ["resinsight", "case"], "title": "SelectEclipseCaseInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.SelectEclipseCaseInput"], "x-ri-python-type": "taskmaestro_resinsight.models.SelectEclipseCaseInput"}, "output_schema": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}}, "resinsight.select_well_path": {"id": "resinsight.select_well_path", "name": "select_well_path", "python_type": "taskmaestro_resinsight.select_well_path.SelectWellPath", "description": "Pass an existing well path through to downstream tasks.", "timeout_seconds": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}, "WellPath": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, "description": "Input for SelectWellPath: RipsInstance from upstream, well path selected via config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "grid_case": {"$ref": "#/$defs/GridCase"}, "well_path": {"$ref": "#/$defs/WellPath", "description": "Well path to use"}}, "required": ["resinsight", "grid_case", "well_path"], "title": "SelectWellPathInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.SelectWellPathInput"], "x-ri-python-type": "taskmaestro_resinsight.models.SelectWellPathInput"}, "output_schema": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, "resinsight.add_perforation": {"id": "resinsight.add_perforation", "name": "add_perforation", "python_type": "taskmaestro_resinsight.add_perforation.AddPerforation", "description": "Add perforation events to a well path.", "timeout_seconds": null, "input_schema": {"$defs": {"RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}, "WellPath": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.WellPath", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.WellPath"}}, "description": "Input for AddPerforation: RipsInstance + well from upstream, MD range from config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "well_path": {"$ref": "#/$defs/WellPath"}, "event_date": {"description": "Perforation event date", "format": "date", "title": "Event Date", "type": "string"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["resinsight", "well_path", "event_date", "start_md", "end_md"], "title": "AddPerforationInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.AddPerforationInput"], "x-ri-python-type": "taskmaestro_resinsight.models.AddPerforationInput"}, "output_schema": {"description": "Output of AddPerforation: perforation interval details.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["value", "start_md", "end_md"], "title": "PerforationOutput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.PerforationOutput", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.PerforationOutput"}}, "resinsight.export_completions": {"id": "resinsight.export_completions", "name": "export_completions", "python_type": "taskmaestro_resinsight.export_completions.ExportCompletions", "description": "Fan-in: merge case and perforations, then export completions.\n\nUses inline Inputs/Outputs inner classes for the named-ports fan-in pattern.\nexport_path comes from config.", "timeout_seconds": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "PerforationOutput": {"description": "Output of AddPerforation: perforation interval details.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.WellPath", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.WellPath", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["value", "start_md", "end_md"], "title": "PerforationOutput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.PerforationOutput", "taskmaestro.object_model.ObjectModel[WellPath]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.PerforationOutput"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "grid_case": {"$ref": "#/$defs/GridCase"}, "perforation_1": {"$ref": "#/$defs/PerforationOutput"}, "perforation_2": {"$ref": "#/$defs/PerforationOutput"}, "event_date": {"description": "Perforation event date", "format": "date", "title": "Event Date", "type": "string"}, "export_path": {"description": "Path to write the completions file", "format": "path", "title": "Export Path", "type": "string"}}, "required": ["resinsight", "grid_case", "perforation_1", "perforation_2", "event_date", "export_path"], "title": "Inputs", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.export_completions.ExportCompletions.Inputs"], "x-ri-python-type": "taskmaestro_resinsight.export_completions.ExportCompletions.Inputs"}, "output_schema": {"properties": {"export_file": {"title": "Export File", "type": "string"}, "well_path_names": {"items": {"type": "string"}, "title": "Well Path Names", "type": "array"}}, "required": ["export_file", "well_path_names"], "title": "Outputs", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.export_completions.ExportCompletions.Outputs"], "x-ri-python-type": "taskmaestro_resinsight.export_completions.ExportCompletions.Outputs"}}}, "describe": {"workflow": "resinsight_completions", "result_task": "export_completions", "tasks": [{"name": "connect_to_resinsight", "python_type": "taskmaestro_resinsight.connect_to_resinsight.ConnectToResInsight", "depends_on": null, "config_fields": [], "provided_config_fields": [], "missing_config_fields": [], "config_values": {}, "required_input_fields": [], "map": null, "input_schema": {"description": "Sentinel config for workflows where all root tasks use JobConfiguration.", "properties": {}, "title": "EmptyConfig", "type": "object"}, "output_schema": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object"}}, {"name": "select_eclipse_case", "python_type": "taskmaestro_resinsight.select_eclipse_case.SelectEclipseCase", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}}, "config_fields": ["case"], "provided_config_fields": [], "missing_config_fields": ["case"], "config_values": {}, "required_input_fields": ["case", "resinsight"], "map": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object"}}, "description": "Input for SelectEclipseCase: RipsInstance from upstream, case selected via config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "case": {"$ref": "#/$defs/GridCase", "description": "Case to use"}}, "required": ["resinsight", "case"], "title": "SelectEclipseCaseInput", "type": "object"}, "output_schema": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object"}}, {"name": "select_well_path_1", "python_type": "taskmaestro_resinsight.select_well_path.SelectWellPath", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}, "grid_case": {"task": "select_eclipse_case", "field": null}}, "config_fields": ["well_path"], "provided_config_fields": [], "missing_config_fields": ["well_path"], "config_values": {}, "required_input_fields": ["grid_case", "resinsight", "well_path"], "map": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object"}, "WellPath": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object"}}, "description": "Input for SelectWellPath: RipsInstance from upstream, well path selected via config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "grid_case": {"$ref": "#/$defs/GridCase"}, "well_path": {"$ref": "#/$defs/WellPath", "description": "Well path to use"}}, "required": ["resinsight", "grid_case", "well_path"], "title": "SelectWellPathInput", "type": "object"}, "output_schema": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object"}}, {"name": "add_perf_1", "python_type": "taskmaestro_resinsight.add_perforation.AddPerforation", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}, "well_path": {"task": "select_well_path_1", "field": null}}, "config_fields": ["end_md", "event_date", "start_md"], "provided_config_fields": ["end_md", "event_date", "start_md"], "missing_config_fields": [], "config_values": {"end_md": 3500.0, "event_date": "2024-01-01", "start_md": 3000.0}, "required_input_fields": ["end_md", "event_date", "resinsight", "start_md", "well_path"], "map": null, "input_schema": {"$defs": {"RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object"}, "WellPath": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object"}}, "description": "Input for AddPerforation: RipsInstance + well from upstream, MD range from config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "well_path": {"$ref": "#/$defs/WellPath"}, "event_date": {"description": "Perforation event date", "format": "date", "title": "Event Date", "type": "string"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["resinsight", "well_path", "event_date", "start_md", "end_md"], "title": "AddPerforationInput", "type": "object"}, "output_schema": {"description": "Output of AddPerforation: perforation interval details.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["value", "start_md", "end_md"], "title": "PerforationOutput", "type": "object"}}, {"name": "select_well_path_2", "python_type": "taskmaestro_resinsight.select_well_path.SelectWellPath", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}, "grid_case": {"task": "select_eclipse_case", "field": null}}, "config_fields": ["well_path"], "provided_config_fields": [], "missing_config_fields": ["well_path"], "config_values": {}, "required_input_fields": ["grid_case", "resinsight", "well_path"], "map": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object"}, "WellPath": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object"}}, "description": "Input for SelectWellPath: RipsInstance from upstream, well path selected via config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "grid_case": {"$ref": "#/$defs/GridCase"}, "well_path": {"$ref": "#/$defs/WellPath", "description": "Well path to use"}}, "required": ["resinsight", "grid_case", "well_path"], "title": "SelectWellPathInput", "type": "object"}, "output_schema": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object"}}, {"name": "add_perf_2", "python_type": "taskmaestro_resinsight.add_perforation.AddPerforation", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}, "well_path": {"task": "select_well_path_2", "field": null}}, "config_fields": ["end_md", "event_date", "start_md"], "provided_config_fields": ["end_md", "event_date", "start_md"], "missing_config_fields": [], "config_values": {"end_md": 2600.0, "event_date": "2024-02-01", "start_md": 2400.0}, "required_input_fields": ["end_md", "event_date", "resinsight", "start_md", "well_path"], "map": null, "input_schema": {"$defs": {"RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object"}, "WellPath": {"description": "Imported well trajectory.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}}, "required": ["value"], "title": "WellPath", "type": "object"}}, "description": "Input for AddPerforation: RipsInstance + well from upstream, MD range from config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "well_path": {"$ref": "#/$defs/WellPath"}, "event_date": {"description": "Perforation event date", "format": "date", "title": "Event Date", "type": "string"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["resinsight", "well_path", "event_date", "start_md", "end_md"], "title": "AddPerforationInput", "type": "object"}, "output_schema": {"description": "Output of AddPerforation: perforation interval details.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["value", "start_md", "end_md"], "title": "PerforationOutput", "type": "object"}}, {"name": "export_completions", "python_type": "taskmaestro_resinsight.export_completions.ExportCompletions", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}, "grid_case": {"task": "select_eclipse_case", "field": null}, "perforation_1": {"task": "add_perf_1", "field": null}, "perforation_2": {"task": "add_perf_2", "field": null}}, "config_fields": ["event_date", "export_path"], "provided_config_fields": ["event_date", "export_path"], "missing_config_fields": [], "config_values": {"event_date": "2024-05-01", "export_path": "/tmp/completions.txt"}, "required_input_fields": ["event_date", "export_path", "grid_case", "perforation_1", "perforation_2", "resinsight"], "map": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object"}, "PerforationOutput": {"description": "Output of AddPerforation: perforation interval details.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.WellPath"}, "start_md": {"title": "Start Md", "type": "number"}, "end_md": {"title": "End Md", "type": "number"}}, "required": ["value", "start_md", "end_md"], "title": "PerforationOutput", "type": "object"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object"}}, "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "grid_case": {"$ref": "#/$defs/GridCase"}, "perforation_1": {"$ref": "#/$defs/PerforationOutput"}, "perforation_2": {"$ref": "#/$defs/PerforationOutput"}, "event_date": {"description": "Perforation event date", "format": "date", "title": "Event Date", "type": "string"}, "export_path": {"description": "Path to write the completions file", "format": "path", "title": "Export Path", "type": "string"}}, "required": ["resinsight", "grid_case", "perforation_1", "perforation_2", "event_date", "export_path"], "title": "Inputs", "type": "object"}, "output_schema": {"properties": {"export_file": {"title": "Export File", "type": "string"}, "well_path_names": {"items": {"type": "string"}, "title": "Well Path Names", "type": "array"}}, "required": ["export_file", "well_path_names"], "title": "Outputs", "type": "object"}}]}, "describe_error": null, "status": "ok"} diff --git a/ApplicationLibCode/UnitTests/TestData/RimWorkflowDefinition/load_data.json b/ApplicationLibCode/UnitTests/TestData/RimWorkflowDefinition/load_data.json new file mode 100644 index 0000000000..8771e14716 --- /dev/null +++ b/ApplicationLibCode/UnitTests/TestData/RimWorkflowDefinition/load_data.json @@ -0,0 +1 @@ +{"format": 1, "name": "resinsight_load_data", "result_task": "import_grid_property", "editable": true, "readonly_reasons": [], "warnings": [], "source": {"workflow_yaml": "test_data/taskmaestro_workflows/resinsight_load_data/workflow.yaml", "input_yaml": "test_data/taskmaestro_workflows/resinsight_load_data/input.yaml", "registered_id": null}, "header_comment": "# ResInsight grid, property, and regular surface loading example.", "passthrough": {}, "nodes": [{"name": "connect_to_resinsight", "task": "resinsight.connect", "config_fields": []}, {"name": "load_model", "task": "resinsight.load_model", "config_fields": ["path"]}, {"name": "import_grid_property", "task": "resinsight.import_grid_property", "config_fields": ["paths"]}, {"name": "load_regular_surface", "task": "resinsight.load_regular_surface", "config_fields": ["path"]}], "edges": [{"from": "connect_to_resinsight", "output": null, "to": "load_model", "input": "resinsight"}, {"from": "connect_to_resinsight", "output": null, "to": "import_grid_property", "input": "resinsight"}, {"from": "load_model", "output": null, "to": "import_grid_property", "input": "grid_case"}, {"from": "connect_to_resinsight", "output": null, "to": "load_regular_surface", "input": "resinsight"}], "inputs": {"connect_to_resinsight": {}, "load_model": {"path": "/path/to/grid.roff"}, "import_grid_property": {"paths": ["/path/to/property.roffasc"]}, "load_regular_surface": {"path": "/path/to/surface.irap"}}, "task_types": {"resinsight.connect": {"id": "resinsight.connect", "name": "connect_to_resinsight", "python_type": "taskmaestro_resinsight.connect_to_resinsight.ConnectToResInsight", "description": "Connect to a running ResInsight instance.", "timeout_seconds": null, "input_schema": {"description": "Sentinel config for workflows where all root tasks use JobConfiguration.", "properties": {}, "title": "EmptyConfig", "type": "object", "x-ri-python-bases": ["taskmaestro.job.EmptyConfig"], "x-ri-python-type": "taskmaestro.job.EmptyConfig"}, "output_schema": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "resinsight.load_model": {"id": "resinsight.load_model", "name": "load_model", "python_type": "taskmaestro_resinsight.load_model.LoadModel", "description": "Open an Eclipse case from a file path and wrap it as a GridCase.", "timeout_seconds": null, "input_schema": {"$defs": {"RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "description": "Input for LoadModel: RipsInstance from upstream, path from config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "path": {"description": "Path to the .EGRID file to load", "title": "Path", "type": "string"}}, "required": ["resinsight", "path"], "title": "LoadModelInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.LoadModelInput"], "x-ri-python-type": "taskmaestro_resinsight.models.LoadModelInput"}, "output_schema": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}}, "resinsight.import_grid_property": {"id": "resinsight.import_grid_property", "name": "import_grid_property", "python_type": "taskmaestro_resinsight.import_grid_property.ImportGridProperty", "description": "Import grid property files into a loaded grid case.", "timeout_seconds": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "description": "Input for ImportGridProperty: RipsInstance, case, and property paths.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "grid_case": {"$ref": "#/$defs/GridCase"}, "paths": {"description": "Paths to grid property files to load", "items": {"type": "string"}, "title": "Paths", "type": "array"}}, "required": ["resinsight", "grid_case", "paths"], "title": "ImportGridPropertyInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.ImportGridPropertyInput"], "x-ri-python-type": "taskmaestro_resinsight.models.ImportGridPropertyInput"}, "output_schema": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Reservoir", "rips.generated.generated_classes.Case", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Reservoir", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.GridCase", "taskmaestro.object_model.ObjectModel[Reservoir]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.GridCase"}}, "resinsight.load_regular_surface": {"id": "resinsight.load_regular_surface", "name": "load_regular_surface", "python_type": "taskmaestro_resinsight.load_regular_surface.LoadRegularSurface", "description": "Import a regular surface file and wrap it as a RegularSurface.", "timeout_seconds": null, "input_schema": {"$defs": {"RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.instance.Instance"], "x-ri-python-type": "rips.instance.Instance", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RipsInstance", "taskmaestro.object_model.ObjectModel[Instance]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RipsInstance"}}, "description": "Input for LoadRegularSurface: RipsInstance + path from config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "path": {"description": "Path to the regular surface file to load", "title": "Path", "type": "string"}}, "required": ["resinsight", "path"], "title": "LoadRegularSurfaceInput", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.LoadRegularSurfaceInput"], "x-ri-python-type": "taskmaestro_resinsight.models.LoadRegularSurfaceInput"}, "output_schema": {"description": "Loaded regular surface file.", "properties": {"value": {"not": {}, "title": "Value", "x-ri-python-bases": ["rips.generated.generated_classes.Surface", "rips.generated.generated_classes.SurfaceInterface", "rips.pdmobject.PdmObjectBase"], "x-ri-python-type": "rips.generated.generated_classes.Surface", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Surface"}}, "required": ["value"], "title": "RegularSurface", "type": "object", "x-ri-python-bases": ["taskmaestro_resinsight.models.RegularSurface", "taskmaestro.object_model.ObjectModel[Surface]", "taskmaestro.object_model.ObjectModel"], "x-ri-python-type": "taskmaestro_resinsight.models.RegularSurface"}}}, "describe": {"workflow": "resinsight_load_data", "result_task": "import_grid_property", "tasks": [{"name": "connect_to_resinsight", "python_type": "taskmaestro_resinsight.connect_to_resinsight.ConnectToResInsight", "depends_on": null, "config_fields": [], "provided_config_fields": [], "missing_config_fields": [], "config_values": {}, "required_input_fields": [], "map": null, "input_schema": {"description": "Sentinel config for workflows where all root tasks use JobConfiguration.", "properties": {}, "title": "EmptyConfig", "type": "object"}, "output_schema": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object"}}, {"name": "load_model", "python_type": "taskmaestro_resinsight.load_model.LoadModel", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}}, "config_fields": ["path"], "provided_config_fields": ["path"], "missing_config_fields": [], "config_values": {"path": "/path/to/grid.roff"}, "required_input_fields": ["path", "resinsight"], "map": null, "input_schema": {"$defs": {"RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object"}}, "description": "Input for LoadModel: RipsInstance from upstream, path from config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "path": {"description": "Path to the .EGRID file to load", "title": "Path", "type": "string"}}, "required": ["resinsight", "path"], "title": "LoadModelInput", "type": "object"}, "output_schema": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object"}}, {"name": "import_grid_property", "python_type": "taskmaestro_resinsight.import_grid_property.ImportGridProperty", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}, "grid_case": {"task": "load_model", "field": null}}, "config_fields": ["paths"], "provided_config_fields": ["paths"], "missing_config_fields": [], "config_values": {"paths": ["/path/to/property.roffasc"]}, "required_input_fields": ["grid_case", "paths", "resinsight"], "map": null, "input_schema": {"$defs": {"GridCase": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object"}, "RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object"}}, "description": "Input for ImportGridProperty: RipsInstance, case, and property paths.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "grid_case": {"$ref": "#/$defs/GridCase"}, "paths": {"description": "Paths to grid property files to load", "items": {"type": "string"}, "title": "Paths", "type": "array"}}, "required": ["resinsight", "grid_case", "paths"], "title": "ImportGridPropertyInput", "type": "object"}, "output_schema": {"description": "Loaded Eclipse reservoir grid case.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Reservoir"}}, "required": ["value"], "title": "GridCase", "type": "object"}}, {"name": "load_regular_surface", "python_type": "taskmaestro_resinsight.load_regular_surface.LoadRegularSurface", "depends_on": {"resinsight": {"task": "connect_to_resinsight", "field": null}}, "config_fields": ["path"], "provided_config_fields": ["path"], "missing_config_fields": [], "config_values": {"path": "/path/to/surface.irap"}, "required_input_fields": ["path", "resinsight"], "map": null, "input_schema": {"$defs": {"RipsInstance": {"description": "Active connection to a running ResInsight application.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.instance.Instance"}}, "required": ["value"], "title": "RipsInstance", "type": "object"}}, "description": "Input for LoadRegularSurface: RipsInstance + path from config.", "properties": {"resinsight": {"$ref": "#/$defs/RipsInstance"}, "path": {"description": "Path to the regular surface file to load", "title": "Path", "type": "string"}}, "required": ["resinsight", "path"], "title": "LoadRegularSurfaceInput", "type": "object"}, "output_schema": {"description": "Loaded regular surface file.", "properties": {"value": {"not": {}, "title": "Value", "x-taskmaestro-opaque": true, "x-taskmaestro-python-type": "rips.generated.generated_classes.Surface"}}, "required": ["value"], "title": "RegularSurface", "type": "object"}}]}, "describe_error": null, "status": "ok"} diff --git a/ApplicationLibCode/UserInterface/CMakeLists_files.cmake b/ApplicationLibCode/UserInterface/CMakeLists_files.cmake index be34bdff69..390bc81252 100644 --- a/ApplicationLibCode/UserInterface/CMakeLists_files.cmake +++ b/ApplicationLibCode/UserInterface/CMakeLists_files.cmake @@ -76,6 +76,10 @@ set(SOURCE_GROUP_SOURCE_FILES ${CMAKE_CURRENT_LIST_DIR}/RiuMessageDialog.cpp ${CMAKE_CURRENT_LIST_DIR}/RiuWorkflowJobRunner.cpp ${CMAKE_CURRENT_LIST_DIR}/RiuWorkflowGraphView.cpp + ${CMAKE_CURRENT_LIST_DIR}/RiuWorkflowGraphLayout.cpp + ${CMAKE_CURRENT_LIST_DIR}/RiuWorkflowTaskPalette.cpp + ${CMAKE_CURRENT_LIST_DIR}/RiuWorkflowEditorWidget.cpp + ${CMAKE_CURRENT_LIST_DIR}/RiuWorkflowValidationRunner.cpp ${CMAKE_CURRENT_LIST_DIR}/RiuPlotObjectPicker.cpp ${CMAKE_CURRENT_LIST_DIR}/RiuContextMenuLauncher.cpp ${CMAKE_CURRENT_LIST_DIR}/RiuSummaryVectorSelectionUi.cpp diff --git a/ApplicationLibCode/UserInterface/RiuMainWindow.cpp b/ApplicationLibCode/UserInterface/RiuMainWindow.cpp index 9787d79799..95f7ace8d9 100644 --- a/ApplicationLibCode/UserInterface/RiuMainWindow.cpp +++ b/ApplicationLibCode/UserInterface/RiuMainWindow.cpp @@ -52,6 +52,7 @@ #include "RimViewLinkerCollection.h" #include "RimViewWindow.h" #include "Workflow/RimWorkflow.h" +#include "Workflow/RimWorkflowCollection.h" #include "Workflow/RimWorkflowFieldBinding.h" #include "Workflow/RimWorkflowJob.h" #include "Workflow/RimWorkflowTaskInput.h" @@ -75,7 +76,7 @@ #include "RiuTools.h" #include "RiuTreeViewEventFilter.h" #include "RiuViewer.h" -#include "RiuWorkflowGraphView.h" +#include "RiuWorkflowEditorWidget.h" #include "cafAnimationToolBar.h" #include "cafCmdExecCommandManager.h" @@ -242,6 +243,11 @@ QString RiuMainWindow::mainWindowName() //-------------------------------------------------------------------------------------------------- void RiuMainWindow::initializeGuiNewProjectLoaded() { + if ( auto* project = RimProject::current(); project && project->workflowCollection() ) + { + project->workflowCollection()->rescanWorkflows(); + } + setPdmRoot( RimProject::current() ); restoreTreeViewState(); @@ -1296,8 +1302,8 @@ void RiuMainWindow::setPdmRoot( caf::PdmObject* pdmRoot ) m_pdmRoot = pdmRoot; if ( !pdmRoot ) { - m_displayedWorkflowJob = nullptr; - if ( m_workflowGraphView ) m_workflowGraphView->showGraph( {}, {} ); + if ( m_workflowEditor ) m_workflowEditor->clear(); + updateWorkflowDockTitle(); } for ( auto tv : projectTreeViews() ) @@ -1626,13 +1632,14 @@ void RiuMainWindow::selectedObjectsChanged() //-------------------------------------------------------------------------------------------------- void RiuMainWindow::workflowBindingChanged( const RimWorkflowFieldBinding* binding ) { - if ( !m_workflowGraphView || !m_displayedWorkflowJob || binding->firstAncestorOrThisOfType() != m_displayedWorkflowJob.p() ) + if ( !m_workflowEditor || !m_workflowEditor->job() || binding->firstAncestorOrThisOfType() != m_workflowEditor->job() ) return; if ( auto* task = binding->firstAncestorOrThisOfType() ) { - m_workflowGraphView->setTaskInputValue( task->taskName(), binding->fieldName(), binding->displayValue() ); + m_workflowEditor->graphView()->setTaskInputValue( task->taskName(), binding->fieldName(), binding->displayValue() ); } + m_workflowEditor->updateTaskIssues(); } //-------------------------------------------------------------------------------------------------- @@ -1640,13 +1647,51 @@ void RiuMainWindow::workflowBindingChanged( const RimWorkflowFieldBinding* bindi //-------------------------------------------------------------------------------------------------- void RiuMainWindow::workflowJobStateChanged( const RimWorkflowJob* job ) { - if ( !m_workflowGraphView || m_displayedWorkflowJob.p() != job ) return; - m_workflowGraphView->resetTaskStates(); - const auto states = job->taskStates(); - const auto errors = job->taskErrors(); - for ( auto it = states.cbegin(); it != states.cend(); ++it ) - m_workflowGraphView->setTaskState( it.key(), it.value(), errors.value( it.key() ) ); - m_workflowGraphView->setRunStatus( job->runStatus() ); + if ( !m_workflowEditor || m_workflowEditor->job() != job ) return; + m_workflowEditor->updateJobState(); +} + +//-------------------------------------------------------------------------------------------------- +/// Called by RimWorkflow after edits, undo, save and validation. A null workflow means the set of +/// workflows changed (rescan). +//-------------------------------------------------------------------------------------------------- +void RiuMainWindow::workflowDefinitionChanged( RimWorkflow* workflow ) +{ + if ( workflow ) workflow->updateConnectedEditors(); + if ( !m_workflowEditor ) return; + + RimWorkflow* displayed = m_workflowEditor->workflow(); + if ( !displayed ) + { + m_workflowEditor->clear(); + } + else if ( !workflow || workflow == displayed ) + { + RimWorkflowJob* job = m_workflowEditor->job(); + if ( !job ) + { + const auto jobs = displayed->jobs(); + if ( !jobs.empty() ) job = jobs.front(); + } + m_workflowEditor->setWorkflow( displayed, job ); + } + updateWorkflowDockTitle(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RimWorkflow* RiuMainWindow::displayedWorkflow() const +{ + return m_workflowEditor ? m_workflowEditor->workflow() : nullptr; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RiuWorkflowEditorWidget* RiuMainWindow::workflowEditor() const +{ + return m_workflowEditor; } //-------------------------------------------------------------------------------------------------- @@ -1657,31 +1702,57 @@ void RiuMainWindow::showWorkflowGraph( RimWorkflow* workflow, RimWorkflowJob* jo if ( !m_workflowGraphDock ) { m_workflowGraphDock = RiuDockWidgetTools::createDockWidget( "Workflow", "WorkflowGraph", dockManager() ); - m_workflowGraphView = new RiuWorkflowGraphView( m_workflowGraphDock ); - m_workflowGraphDock->setWidget( m_workflowGraphView ); + m_workflowEditor = new RiuWorkflowEditorWidget( m_workflowGraphDock ); + m_workflowGraphDock->setWidget( m_workflowEditor ); dockManager()->addDockWidget( ads::DockWidgetArea::CenterDockWidgetArea, m_workflowGraphDock, dockManager()->centralWidget()->dockAreaWidget() ); + connect( m_workflowEditor, &RiuWorkflowEditorWidget::taskSelected, this, &RiuMainWindow::showWorkflowTaskProperties ); } - m_displayedWorkflowJob = job; - m_workflowGraphDock->setWindowTitle( QString( "Workflow: %1" ).arg( workflow->name() ) ); - m_workflowGraphView->showGraph( workflow->graph(), workflow->loadError() ); - if ( job ) - { - for ( RimWorkflowTaskInput* task : job->taskInputs() ) - { - for ( RimWorkflowFieldBinding* binding : task->items() ) - { - if ( binding ) m_workflowGraphView->setTaskInputValue( task->taskName(), binding->fieldName(), binding->displayValue() ); - } - } - } - if ( job ) workflowJobStateChanged( job ); + m_workflowEditor->setWorkflow( workflow, job ); + updateWorkflowDockTitle(); m_workflowGraphDock->toggleView( true ); m_workflowGraphDock->setAsCurrentTab(); } +//-------------------------------------------------------------------------------------------------- +/// Show the inputs of the selected graph node, or the job when the selection is cleared +//-------------------------------------------------------------------------------------------------- +void RiuMainWindow::showWorkflowTaskProperties( const QString& taskName ) +{ + if ( !m_workflowEditor ) return; + + RimWorkflowJob* job = m_workflowEditor->job(); + if ( !job ) return; + + if ( RimWorkflowTaskInput* task = taskName.isEmpty() ? nullptr : job->taskInput( taskName ) ) + { + m_pdmUiPropertyView->showProperties( task ); + } + else if ( dynamic_cast( m_pdmUiPropertyView->currentObject() ) ) + { + m_pdmUiPropertyView->showProperties( job ); + } +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuMainWindow::updateWorkflowDockTitle() +{ + if ( !m_workflowGraphDock ) return; + + RimWorkflow* workflow = displayedWorkflow(); + if ( !workflow ) + { + m_workflowGraphDock->setWindowTitle( "Workflow" ); + return; + } + const bool modified = workflow->isEditable() && workflow->isDirty(); + m_workflowGraphDock->setWindowTitle( QString( "Workflow: %1%2" ).arg( workflow->name(), modified ? "*" : "" ) ); +} + //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- diff --git a/ApplicationLibCode/UserInterface/RiuMainWindow.h b/ApplicationLibCode/UserInterface/RiuMainWindow.h index 874d7310d0..f545a90ce3 100644 --- a/ApplicationLibCode/UserInterface/RiuMainWindow.h +++ b/ApplicationLibCode/UserInterface/RiuMainWindow.h @@ -58,7 +58,7 @@ class RiuPvtPlotPanel; class RiuMohrsCirclePlot; class RiuSeismicHistogramPanel; class RiuCellSelectionTool; -class RiuWorkflowGraphView; +class RiuWorkflowEditorWidget; class RicGridCalculatorDialog; @@ -132,8 +132,11 @@ public: RicGridCalculatorDialog* gridCalculatorDialog( bool createIfNotPresent ); - void workflowBindingChanged( const RimWorkflowFieldBinding* binding ); - void workflowJobStateChanged( const RimWorkflowJob* job ); + void workflowBindingChanged( const RimWorkflowFieldBinding* binding ); + void workflowJobStateChanged( const RimWorkflowJob* job ); + void workflowDefinitionChanged( RimWorkflow* workflow ); + RimWorkflow* displayedWorkflow() const; + RiuWorkflowEditorWidget* workflowEditor() const; void restartBatchMonitor(); @@ -150,6 +153,8 @@ private: void createToolBars(); void createDockPanels(); void showWorkflowGraph( RimWorkflow* workflow, RimWorkflowJob* job ); + void showWorkflowTaskProperties( const QString& taskName ); + void updateWorkflowDockTitle(); void restoreTreeViewState(); @@ -291,6 +296,5 @@ private: std::vector> m_additionalProjectViews; QPointer m_workflowGraphDock; - QPointer m_workflowGraphView; - caf::PdmPointer m_displayedWorkflowJob; + QPointer m_workflowEditor; }; diff --git a/ApplicationLibCode/UserInterface/RiuWorkflowEditorWidget.cpp b/ApplicationLibCode/UserInterface/RiuWorkflowEditorWidget.cpp new file mode 100644 index 0000000000..b0351eadb0 --- /dev/null +++ b/ApplicationLibCode/UserInterface/RiuWorkflowEditorWidget.cpp @@ -0,0 +1,898 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#include "RiuWorkflowEditorWidget.h" + +#include "RiuWorkflowTaskPalette.h" +#include "RiuWorkflowValidationRunner.h" + +#include "WorkflowCommands/RicWorkflowFeatureTools.h" + +#include "Workflow/RimWorkflow.h" +#include "Workflow/RimWorkflowDefinition.h" +#include "Workflow/RimWorkflowDefinitionTools.h" +#include "Workflow/RimWorkflowFieldBinding.h" +#include "Workflow/RimWorkflowJob.h" +#include "Workflow/RimWorkflowPortCompatibility.h" +#include "Workflow/RimWorkflowTaskCatalog.h" +#include "Workflow/RimWorkflowTaskInput.h" +#include "Workflow/RimWorkflowValidationTools.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace +{ +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QAction* addMenuAction( QMenu* menu, const QString& text, std::function slot, const QString& icon = {} ) +{ + QAction* action = menu->addAction( icon.isEmpty() ? QIcon() : QIcon( icon ), text ); + QObject::connect( action, &QAction::triggered, menu, std::move( slot ) ); + return action; +} +} // namespace + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RiuWorkflowEditorWidget::RiuWorkflowEditorWidget( QWidget* parent ) + : QWidget( parent ) +{ + auto* layout = new QVBoxLayout( this ); + layout->setContentsMargins( 0, 0, 0, 0 ); + layout->setSpacing( 2 ); + + auto addAction = [this]( const QString& text, const QString& icon, auto slot ) + { + auto* action = new QAction( icon.isEmpty() ? QIcon() : QIcon( icon ), text, this ); + connect( action, &QAction::triggered, this, slot ); + return action; + }; + + m_saveAction = addAction( "Save", + ":/Save.svg", + [this]() + { + if ( m_workflow ) RicWorkflowFeatureTools::saveWorkflow( m_workflow ); + } ); + m_saveAsAction = addAction( "Save As...", + ":/SaveAs.svg", + [this]() + { + if ( m_workflow ) RicWorkflowFeatureTools::saveWorkflowAs( m_workflow ); + } ); + m_undoAction = addAction( "Undo", + ":/undo.png", + [this]() + { + if ( !m_workflow || !m_workflow->canUndo() || m_workflow->isLocked() ) return; + renameNodePositions( m_workflow->undoRenames() ); + m_workflow->undo(); + } ); + m_redoAction = addAction( "Redo", + ":/redo.png", + [this]() + { + if ( !m_workflow || !m_workflow->canRedo() || m_workflow->isLocked() ) return; + renameNodePositions( m_workflow->redoRenames() ); + m_workflow->redo(); + } ); + m_duplicateAction = addAction( "Duplicate as Editable", + ":/Copy.svg", + [this]() + { + if ( m_workflow ) RicWorkflowFeatureTools::duplicateAsEditable( m_workflow ); + } ); + m_fitAction = addAction( "Fit", "", [this]() { m_graphView->fitGraph(); } ); + + m_splitter = new QSplitter( Qt::Horizontal, this ); + m_palette = new RiuWorkflowTaskPalette( m_splitter ); + m_graphView = new RiuWorkflowGraphView( m_splitter ); + m_splitter->addWidget( m_graphView ); + m_splitter->addWidget( m_palette ); + m_splitter->setStretchFactor( 0, 1 ); + m_splitter->setStretchFactor( 1, 0 ); + m_splitter->setSizes( { 800, 220 } ); + layout->addWidget( m_splitter, 1 ); + + m_statusLabel = new QLabel( this ); + m_statusLabel->setWordWrap( true ); + m_statusLabel->setTextInteractionFlags( Qt::TextSelectableByMouse ); + m_statusLabel->setContentsMargins( 4, 2, 4, 2 ); + layout->addWidget( m_statusLabel ); + + m_validationRunner = new RiuWorkflowValidationRunner( this ); + connect( m_validationRunner, &RiuWorkflowValidationRunner::validationStarted, this, &RiuWorkflowEditorWidget::updateStatus ); + connect( m_validationRunner, &RiuWorkflowValidationRunner::validationFinished, this, &RiuWorkflowEditorWidget::onValidationFinished ); + + m_graphView->setConnectionValidator( + [this]( const QString& from, const QString& output, const QString& to, const QString& input ) -> std::expected + { + if ( !m_workflow ) return std::unexpected( QString( "No workflow" ) ); + return RimWorkflowPortCompatibility::canConnect( m_workflow->definition(), from, output, to, input ); + } ); + + connect( m_graphView, &RiuWorkflowGraphView::nodeSelected, this, &RiuWorkflowEditorWidget::taskSelected ); + connect( m_graphView, &RiuWorkflowGraphView::connectRequested, this, &RiuWorkflowEditorWidget::connectPorts ); + connect( m_graphView, &RiuWorkflowGraphView::deleteRequested, this, &RiuWorkflowEditorWidget::deleteItem ); + connect( m_graphView, &RiuWorkflowGraphView::renameRequested, this, &RiuWorkflowEditorWidget::renameTask ); + connect( m_graphView, + &RiuWorkflowGraphView::addTaskRequested, + this, + [this]( const QString& taskId, const QPointF& scenePos ) { addTask( taskId, scenePos ); } ); + connect( m_graphView, &RiuWorkflowGraphView::contextMenuRequested, this, &RiuWorkflowEditorWidget::showContextMenu ); + connect( m_palette, &RiuWorkflowTaskPalette::taskActivated, this, [this]( const QString& taskId ) { addTask( taskId ); } ); + + updateActions(); + updateStatus(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RiuWorkflowEditorWidget::~RiuWorkflowEditorWidget() +{ + m_validationRunner->cancel(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::setWorkflow( RimWorkflow* workflow, RimWorkflowJob* job ) +{ + const bool workflowChanged = workflow != m_workflow.p(); + if ( workflowChanged ) + { + m_validationRunner->cancel(); + m_message.clear(); + } + + m_workflow = workflow; + m_job = job; + showWorkflow( workflowChanged ? RiuWorkflowGraphView::ViewportPolicy::Fit : RiuWorkflowGraphView::ViewportPolicy::Keep ); + scheduleValidation(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RimWorkflow* RiuWorkflowEditorWidget::workflow() const +{ + return m_workflow.p(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RimWorkflowJob* RiuWorkflowEditorWidget::job() const +{ + return m_job.p(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::clear() +{ + m_validationRunner->cancel(); + m_workflow = nullptr; + m_job = nullptr; + m_message.clear(); + showWorkflow( RiuWorkflowGraphView::ViewportPolicy::Fit ); +} + +//-------------------------------------------------------------------------------------------------- +/// Show the current revision of the workflow without moving the view +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::refresh() +{ + showWorkflow( RiuWorkflowGraphView::ViewportPolicy::Keep ); + scheduleValidation(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::updateJobState() +{ + m_graphView->resetTaskStates(); + if ( !m_job ) return; + + const auto states = m_job->taskStates(); + const auto errors = m_job->taskErrors(); + for ( auto it = states.cbegin(); it != states.cend(); ++it ) + m_graphView->setTaskState( it.key(), it.value(), errors.value( it.key() ) ); + + const auto itemStates = m_job->itemStates(); + const auto itemErrors = m_job->itemErrors(); + for ( auto task = itemStates.cbegin(); task != itemStates.cend(); ++task ) + { + for ( auto item = task.value().cbegin(); item != task.value().cend(); ++item ) + m_graphView->setTaskItemState( task.key(), item.key(), item.value(), itemErrors.value( task.key() ).value( item.key() ) ); + } + m_graphView->setRunStatus( m_job->runStatus() ); + + // A running job locks the workflow + const bool editable = m_workflow && m_workflow->isEditable() && !m_workflow->isLocked(); + m_graphView->setEditable( editable ); + m_palette->setEnabled( editable ); + updateActions(); + updateStatus(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::updateTaskInputValues() +{ + if ( !m_job ) return; + for ( RimWorkflowTaskInput* task : m_job->taskInputs() ) + { + for ( RimWorkflowFieldBinding* binding : task->items() ) + { + if ( binding ) m_graphView->setTaskInputValue( task->taskName(), binding->fieldName(), binding->displayValue() ); + } + } + updateTaskIssues(); +} + +//-------------------------------------------------------------------------------------------------- +/// Show the issues of the workflow, with missing-value warnings taken from the displayed job +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::updateTaskIssues() +{ + if ( !m_workflow || !m_job ) return; + + for ( const QJsonValue& value : m_workflow->graph().value( "tasks" ).toArray() ) + { + const QJsonObject graphTask = value.toObject(); + const QString taskName = graphTask.value( "name" ).toString(); + RimWorkflowTaskInput* taskInput = m_job->taskInput( taskName ); + + std::map fieldHasValue; + for ( const QJsonValue& field : graphTask.value( "config_fields" ).toArray() ) + { + const QJsonObject schema = field.toObject(); + const QString fieldName = schema.value( "name" ).toString(); + bool hasValue = false; + if ( taskInput ) + { + for ( const RimWorkflowFieldBinding* binding : taskInput->items() ) + { + if ( binding && binding->fieldName() == fieldName ) hasValue = !binding->toJsonValue().isNull(); + } + } + fieldHasValue[fieldName] = hasValue || !schema.value( "required" ).toBool(); + } + + m_graphView->setTaskIssues( taskName, + RimWorkflowValidationTools::issuesWithJobValues( taskName, + graphTask.value( "issues" ).toArray(), + fieldHasValue ) ); + } +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RiuWorkflowGraphView* RiuWorkflowEditorWidget::graphView() const +{ + return m_graphView; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RiuWorkflowTaskPalette* RiuWorkflowEditorWidget::palette() const +{ + return m_palette; +} + +//-------------------------------------------------------------------------------------------------- +/// Apply an edit to the displayed workflow. The workflow notifies the main window, which refreshes +/// this widget. +//-------------------------------------------------------------------------------------------------- +bool RiuWorkflowEditorWidget::applyEdit( const std::expected& result, const QMap& renames ) +{ + if ( !m_workflow ) return false; + + auto applied = m_workflow->applyEdit( result, renames ); + if ( !applied ) + { + showMessage( applied.error() ); + return false; + } + + m_message.clear(); + updateActions(); + updateStatus(); + return true; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::addTask( const QString& taskId, const std::optional& scenePos ) +{ + if ( !canEdit() ) return; + + QString addedName; + const auto result = RimWorkflowDefinitionTools::addTask( m_workflow->definition(), RimWorkflowTaskCatalog::instance(), taskId, &addedName ); + if ( result && scenePos ) m_graphView->setPendingNodePosition( addedName, *scenePos ); + if ( applyEdit( result ) ) m_graphView->selectTask( addedName ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::deleteItem( const RiuWorkflowGraphView::ItemRef& item ) +{ + if ( !canEdit() ) return; + + if ( item.kind == RiuWorkflowGraphView::ItemRef::Kind::Task ) + { + applyEdit( RimWorkflowDefinitionTools::removeTask( m_workflow->definition(), item.task ) ); + } + else if ( item.kind == RiuWorkflowGraphView::ItemRef::Kind::Edge ) + { + const RimWorkflowDefinitionEdge edge{ .from = item.from, .output = item.output, .to = item.to, .input = item.input }; + applyEdit( RimWorkflowDefinitionTools::disconnect( m_workflow->definition(), edge ) ); + } +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::renameTask( const QString& taskName ) +{ + if ( !canEdit() || !m_workflow->definition().findNode( taskName ) ) return; + + bool ok = false; + const QString newName = + QInputDialog::getText( this, "Rename Task", "Task name (letters, digits and underscores):", QLineEdit::Normal, taskName, &ok ).trimmed(); + if ( !ok || newName == taskName ) return; + + // The workflow may have changed or been deleted while the dialog was open + if ( !canEdit() ) return; + + const auto result = RimWorkflowDefinitionTools::renameTask( m_workflow->definition(), taskName, newName ); + if ( result ) m_graphView->renameNodePosition( taskName, newName ); + if ( applyEdit( result, { { taskName, newName } } ) ) + { + m_graphView->selectTask( newName ); + } + else if ( result ) + { + m_graphView->renameNodePosition( newName, taskName ); + } +} + +//-------------------------------------------------------------------------------------------------- +/// Let renamed tasks keep their place in the graph +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::renameNodePositions( const QMap& renames ) +{ + for ( auto it = renames.cbegin(); it != renames.cend(); ++it ) + m_graphView->renameNodePosition( it.key(), it.value() ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::setResultTask( const QString& taskName ) +{ + if ( !canEdit() ) return; + applyEdit( RimWorkflowDefinitionTools::setResultTask( m_workflow->definition(), taskName ) ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::setOptionalInputConfigured( const QString& taskName, const QString& field, bool configured ) +{ + if ( !canEdit() ) return; + applyEdit( RimWorkflowDefinitionTools::setOptionalInputConfigured( m_workflow->definition(), taskName, field, configured ) ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::connectPorts( const QString& from, const QString& output, const QString& to, const QString& input ) +{ + if ( !canEdit() ) return; + const RimWorkflowDefinitionEdge edge{ .from = from, .output = output, .to = to, .input = input }; + applyEdit( RimWorkflowDefinitionTools::connect( m_workflow->definition(), edge ) ); +} + +//-------------------------------------------------------------------------------------------------- +/// Shows why the workflow cannot be edited +//-------------------------------------------------------------------------------------------------- +bool RiuWorkflowEditorWidget::canEdit() +{ + if ( !m_workflow ) return false; + if ( m_workflow->isEditable() && !m_workflow->isLocked() ) return true; + + showMessage( m_workflow->isLocked() ? QString( "The workflow is locked while a job is running" ) : m_workflow->readOnlyReason() ); + return false; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +const RimWorkflowDefinitionEdge* RiuWorkflowEditorWidget::findEdge( const RiuWorkflowGraphView::ItemRef& item ) const +{ + if ( !m_workflow || item.kind != RiuWorkflowGraphView::ItemRef::Kind::Edge ) return nullptr; + + const RimWorkflowDefinitionEdge wanted{ .from = item.from, .output = item.output, .to = item.to, .input = item.input }; + for ( const auto& edge : m_workflow->definition().edges ) + { + if ( edge.sameConnection( wanted ) ) return &edge; + } + return nullptr; +} + +//-------------------------------------------------------------------------------------------------- +/// Run the task once per item of a mapping. The dialog edits the current map, if any. +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::editTaskMap( const QString& taskName ) +{ + if ( !canEdit() ) return; + + // A copy, as the workflow may change or be deleted while the dialog is open + const RimWorkflowDefinition definition = m_workflow->definition(); + const RimWorkflowDefinitionNode* node = definition.findNode( taskName ); + if ( !node ) return; + + QStringList fields; + for ( const auto& port : RimWorkflowPortCompatibility::inputPorts( definition.taskType( node->taskId ) ) ) + { + if ( !port.isWhole() ) fields.append( port.name ); + } + if ( fields.size() < 2 ) + { + showMessage( QString( "Task '%1' needs two inputs to receive the key and the value of each item" ).arg( taskName ) ); + return; + } + + const RimWorkflowTaskMap current = node->map.value_or( RimWorkflowTaskMap{ .over = "items" } ); + + QDialog dialog( this ); + dialog.setWindowTitle( QString( "Map Over - %1" ).arg( taskName ) ); + auto* form = new QFormLayout( &dialog ); + + auto* overEdit = new QLineEdit( current.over, &dialog ); + overEdit->setToolTip( "Name of the configured mapping. Each key and value of the mapping runs the task once." ); + form->addRow( "Map over:", overEdit ); + + auto addFieldCombo = [&]( const QString& label, const QString& value, const QString& fallback, const QString& toolTip ) + { + auto* combo = new QComboBox( &dialog ); + combo->addItems( fields ); + combo->setCurrentText( fields.contains( value ) ? value : fallback ); + combo->setToolTip( toolTip ); + form->addRow( label, combo ); + return combo; + }; + auto* keyCombo = addFieldCombo( "Key goes to:", current.keyAs, fields.front(), "The input that receives the key of each item" ); + auto* valueCombo = addFieldCombo( "Value goes to:", current.valueAs, fields.at( 1 ), "The input that receives the value of each item" ); + + auto* errorCombo = new QComboBox( &dialog ); + errorCombo->addItem( "Stop at the first failed item", "fail_fast" ); + errorCombo->addItem( "Run every item, then fail if any failed", "collect_all" ); + errorCombo->setCurrentIndex( std::max( 0, errorCombo->findData( current.errorMode ) ) ); + form->addRow( "On failure:", errorCombo ); + + auto* errorLabel = new QLabel( &dialog ); + errorLabel->setStyleSheet( "color: #b00020;" ); + errorLabel->setWordWrap( true ); + form->addRow( errorLabel ); + + auto* buttons = new QDialogButtonBox( QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &dialog ); + form->addRow( buttons ); + + auto mapFromDialog = [&]() + { + return RimWorkflowTaskMap{ .over = overEdit->text().trimmed(), + .keyAs = keyCombo->currentText(), + .valueAs = valueCombo->currentText(), + .errorMode = errorCombo->currentData().toString() }; + }; + auto updateValidity = [&]() + { + const auto valid = RimWorkflowDefinitionTools::checkTaskMap( definition, taskName, mapFromDialog() ); + errorLabel->setText( valid ? QString() : valid.error() ); + errorLabel->setVisible( !valid ); + buttons->button( QDialogButtonBox::Ok )->setEnabled( valid.has_value() ); + }; + QObject::connect( overEdit, &QLineEdit::textChanged, &dialog, updateValidity ); + QObject::connect( keyCombo, &QComboBox::currentIndexChanged, &dialog, updateValidity ); + QObject::connect( valueCombo, &QComboBox::currentIndexChanged, &dialog, updateValidity ); + QObject::connect( buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept ); + QObject::connect( buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject ); + updateValidity(); + + if ( dialog.exec() != QDialog::Accepted || !canEdit() ) return; + + const RimWorkflowDefinitionNode* currentNode = m_workflow->definition().findNode( taskName ); + if ( !currentNode ) return; + + const RimWorkflowTaskMap map = mapFromDialog(); + if ( currentNode->map && *currentNode->map == map ) return; + applyEdit( RimWorkflowDefinitionTools::setTaskMap( m_workflow->definition(), taskName, map ) ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::removeTaskMap( const QString& taskName ) +{ + if ( !canEdit() ) return; + applyEdit( RimWorkflowDefinitionTools::setTaskMap( m_workflow->definition(), taskName, std::nullopt ) ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::renameCollectKey( const RiuWorkflowGraphView::ItemRef& item ) +{ + if ( !canEdit() ) return; + const RimWorkflowDefinitionEdge* found = findEdge( item ); + if ( !found || found->collect != RimWorkflowDefinitionEdge::Collect::Dict ) return; + + // A copy, as the workflow may change or be deleted while the dialog is open + const RimWorkflowDefinitionEdge edge = *found; + + bool ok = false; + const QString key = + QInputDialog::getText( this, "Rename Key", QString( "Key of '%1' in '%2':" ).arg( edge.from, edge.input ), QLineEdit::Normal, edge.key, &ok ) + .trimmed(); + if ( !ok || key == edge.key || !canEdit() ) return; + + applyEdit( RimWorkflowDefinitionTools::setCollectKey( m_workflow->definition(), edge, key ) ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::moveCollectMember( const RiuWorkflowGraphView::ItemRef& item, int delta ) +{ + if ( !canEdit() ) return; + const RimWorkflowDefinitionEdge* edge = findEdge( item ); + if ( !edge ) return; + + applyEdit( RimWorkflowDefinitionTools::moveCollectMember( m_workflow->definition(), *edge, delta ) ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::showMessage( const QString& message ) +{ + m_message = message; + updateStatus(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::showWorkflow( RiuWorkflowGraphView::ViewportPolicy policy ) +{ + if ( !m_workflow ) + { + m_graphView->setEditable( false ); + m_graphView->showGraph( {}, {}, policy ); + } + else + { + m_graphView->setEditable( m_workflow->isEditable() && !m_workflow->isLocked() ); + m_graphView->showGraph( m_workflow->graph(), m_workflow->loadError(), policy ); + updateTaskInputValues(); + if ( m_job ) updateJobState(); + } + + m_palette->setEnabled( m_workflow && m_workflow->isEditable() && !m_workflow->isLocked() ); + updateActions(); + updateStatus(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::showContextMenu( const RiuWorkflowGraphView::ItemRef& item, const QPoint& globalPos, const QPointF& scenePos ) +{ + if ( !m_workflow ) return; + + const bool editable = m_workflow->isEditable() && !m_workflow->isLocked(); + + QMenu menu; + if ( item.kind == RiuWorkflowGraphView::ItemRef::Kind::Task && editable ) + { + const QString task = item.task; + addMenuAction( &menu, "Rename...", [this, task]() { renameTask( task ); } ); + addMenuAction( &menu, "Set as Result Task", [this, task]() { setResultTask( task ); } ) + ->setEnabled( RimWorkflowDefinitionTools::effectiveResultTask( m_workflow->definition() ) != task || + m_workflow->definition().resultTask.isEmpty() ); + appendOptionalInputsMenu( &menu, task ); + appendMapMenu( &menu, task ); + menu.addSeparator(); + addMenuAction( &menu, "Delete Task", [this, item]() { deleteItem( item ); }, ":/Erase.svg" ); + } + else if ( item.kind == RiuWorkflowGraphView::ItemRef::Kind::Edge && editable ) + { + appendCollectMenu( &menu, item ); + addMenuAction( &menu, "Delete Connection", [this, item]() { deleteItem( item ); }, ":/Erase.svg" ); + } + else + { + if ( editable ) + { + appendAddTaskMenu( &menu, scenePos ); + menu.addSeparator(); + menu.addAction( m_undoAction ); + menu.addAction( m_redoAction ); + menu.addSeparator(); + menu.addAction( m_saveAction ); + menu.addAction( m_saveAsAction ); + } + menu.addAction( m_duplicateAction ); + menu.addSeparator(); + menu.addAction( m_fitAction ); + } + + if ( !menu.isEmpty() ) menu.exec( globalPos ); +} + +//-------------------------------------------------------------------------------------------------- +/// The installed task types, grouped by package +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::appendAddTaskMenu( QMenu* menu, const QPointF& scenePos ) +{ + QMenu* addMenu = menu->addMenu( "Add Task" ); + + const RimWorkflowTaskCatalog& catalog = RimWorkflowTaskCatalog::instance(); + if ( !catalog.isValid() ) + { + addMenu->addAction( QString( "Task catalog unavailable: %1" ).arg( catalog.loadError() ) )->setEnabled( false ); + return; + } + + std::map> groups; + for ( const QJsonObject& task : catalog.tasks() ) + { + const QString id = task.value( "id" ).toString(); + groups[id.contains( '.' ) ? id.section( '.', 0, -2 ) : QString( "other" )].push_back( task ); + } + + QSet usedTexts; + for ( auto& [group, tasks] : groups ) + { + std::sort( tasks.begin(), + tasks.end(), + []( const QJsonObject& a, const QJsonObject& b ) { return a.value( "id" ).toString() < b.value( "id" ).toString(); } ); + + QMenu* groupMenu = groups.size() > 1 ? addMenu->addMenu( group ) : addMenu; + for ( const QJsonObject& task : tasks ) + { + const QString id = task.value( "id" ).toString(); + QString text = task.value( "name" ).toString( id ); + if ( text.isEmpty() || usedTexts.contains( text ) ) text = id; + usedTexts.insert( text ); + + addMenuAction( + groupMenu, text, [this, id, scenePos]() { addTask( id, scenePos ); }, ":/Plus.png" ) + ->setToolTip( task.value( "description" ).toString() ); + } + } + addMenu->setToolTipsVisible( true ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::appendMapMenu( QMenu* menu, const QString& taskName ) +{ + const RimWorkflowDefinitionNode* node = m_workflow->definition().findNode( taskName ); + if ( !node ) return; + + addMenuAction( menu, node->map ? "Edit Map..." : "Map Over...", [this, taskName]() { editTaskMap( taskName ); }, ":/Refresh.svg" )->setToolTip( "Run the task once for each item of a mapping and collect the outputs by key" ); + if ( node->map ) addMenuAction( menu, "Remove Map", [this, taskName]() { removeTaskMap( taskName ); } ); +} + +//-------------------------------------------------------------------------------------------------- +/// Collected connections: dict members have a key, list members an order +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::appendCollectMenu( QMenu* menu, const RiuWorkflowGraphView::ItemRef& item ) +{ + const RimWorkflowDefinitionEdge* edge = findEdge( item ); + if ( !edge || !edge->isCollected() ) return; + + if ( edge->collect == RimWorkflowDefinitionEdge::Collect::Dict ) + { + addMenuAction( menu, "Rename Key...", [this, item]() { renameCollectKey( item ); } ); + } + else + { + int index = 0; + int count = 0; + for ( const auto& other : m_workflow->definition().edges ) + { + if ( other.to != edge->to || other.input != edge->input || !other.isCollected() ) continue; + if ( &other == edge ) index = count; + ++count; + } + + for ( const auto& [text, delta] : { std::pair{ QString( "Move Earlier" ), -1 }, std::pair{ QString( "Move Later" ), 1 } } ) + { + addMenuAction( menu, text, [this, item, delta = delta]() { moveCollectMember( item, delta ); } ) + ->setEnabled( index + delta >= 0 && index + delta < count ); + } + } + menu->addSeparator(); +} + +//-------------------------------------------------------------------------------------------------- +/// Optional inputs are configured by the user only when checked; connected inputs are disabled +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::appendOptionalInputsMenu( QMenu* menu, const QString& taskName ) +{ + const RimWorkflowDefinition& definition = m_workflow->definition(); + const RimWorkflowDefinitionNode* node = definition.findNode( taskName ); + if ( !node ) return; + + std::vector optionalPorts; + for ( const auto& port : RimWorkflowPortCompatibility::inputPorts( definition.taskTypeForNode( taskName ) ) ) + { + if ( !port.isWhole() && !port.required ) optionalPorts.push_back( port ); + } + if ( optionalPorts.empty() ) return; + + QStringList connected; + for ( const auto& edge : definition.incomingEdges( taskName ) ) + connected.append( edge.input ); + + QMenu* optionalMenu = menu->addMenu( "Optional Inputs" ); + for ( const auto& port : optionalPorts ) + { + const bool configured = node->explicitConfigFields.contains( port.name ); + const QString field = port.name; + QAction* action = addMenuAction( optionalMenu, + field, + [this, taskName, field, configured]() + { setOptionalInputConfigured( taskName, field, !configured ); } ); + action->setCheckable( true ); + action->setChecked( configured ); + action->setEnabled( !connected.contains( field ) ); + action->setToolTip( port.description ); + } + optionalMenu->setToolTipsVisible( true ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::updateActions() +{ + RimWorkflow* workflow = m_workflow.p(); + const bool editable = workflow && workflow->isEditable() && !workflow->isLocked(); + + m_saveAction->setEnabled( editable && ( workflow->isDirty() || workflow->source() == RimWorkflow::Source::Unsaved ) ); + m_saveAsAction->setEnabled( editable ); + m_undoAction->setEnabled( editable && workflow->canUndo() ); + m_redoAction->setEnabled( editable && workflow->canRedo() ); + m_duplicateAction->setEnabled( workflow && workflow->loadError().isEmpty() && workflow->definition().editable ); + m_fitAction->setEnabled( workflow != nullptr ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::updateStatus() +{ + RimWorkflow* workflow = m_workflow.p(); + QStringList lines; + QString color; + + if ( !workflow ) + { + m_statusLabel->clear(); + return; + } + + if ( !workflow->loadError().isEmpty() ) + { + lines << workflow->loadError(); + color = "#c0392b"; + } + else if ( !workflow->isEditable() ) + { + lines << QString( "Read-only: %1. Use Duplicate as Editable to change it." ).arg( workflow->readOnlyReason() ); + color = "#7f8c8d"; + } + else + { + if ( !m_message.isEmpty() ) + { + lines << m_message; + color = "#c0392b"; + } + if ( workflow->isLocked() ) lines << "The workflow is locked while a job is running."; + + const QJsonArray issues = workflow->graph().value( "issues" ).toArray(); + for ( const QJsonValue& issue : issues ) + lines << issue.toObject().value( "message" ).toString(); + + if ( m_validationRunner->isRunning() || !workflow->isValidationCurrent() ) + { + lines << "Validating..."; + } + else if ( workflow->lastValidationFailed() ) + { + if ( issues.isEmpty() ) lines << "taskmaestro reports problems; see the marked tasks."; + if ( color.isEmpty() ) color = "#d35400"; + } + else if ( issues.isEmpty() && m_message.isEmpty() ) + { + lines << "Valid"; + if ( color.isEmpty() ) color = "#27ae60"; + } + if ( !issues.isEmpty() && color.isEmpty() ) color = "#d35400"; + + if ( workflow->source() == RimWorkflow::Source::Unsaved ) + lines << "Not saved yet"; + else if ( workflow->isDirty() ) + lines << "Unsaved changes"; + } + + m_statusLabel->setStyleSheet( color.isEmpty() ? QString() : QString( "color: %1;" ).arg( color ) ); + m_statusLabel->setText( lines.join( " | " ) ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::scheduleValidation() +{ + if ( m_workflow && m_workflow->isEditable() && !m_workflow->isValidationCurrent() ) m_validationRunner->schedule( m_workflow ); + updateStatus(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowEditorWidget::onValidationFinished( RimWorkflow* workflow ) +{ + if ( workflow != m_workflow.p() ) return; + updateActions(); + updateStatus(); +} diff --git a/ApplicationLibCode/UserInterface/RiuWorkflowEditorWidget.h b/ApplicationLibCode/UserInterface/RiuWorkflowEditorWidget.h new file mode 100644 index 0000000000..fef5111d3c --- /dev/null +++ b/ApplicationLibCode/UserInterface/RiuWorkflowEditorWidget.h @@ -0,0 +1,116 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "RiuWorkflowGraphView.h" + +#include "cafPdmPointer.h" + +#include + +#include +#include + +class QAction; +class QLabel; +class QMenu; +class QSplitter; +class RimWorkflow; +class RimWorkflowJob; +struct RimWorkflowDefinition; +struct RimWorkflowDefinitionEdge; +class RiuWorkflowTaskPalette; +class RiuWorkflowValidationRunner; + +//================================================================================================== +/// The workflow dock: the graph, the task palette and a status line. Edits go through +/// RimWorkflow::applyEdit(); the workflow then calls RiuMainWindow::workflowDefinitionChanged(), +/// which refreshes this widget. +//================================================================================================== +class RiuWorkflowEditorWidget : public QWidget +{ + Q_OBJECT + +public: + explicit RiuWorkflowEditorWidget( QWidget* parent = nullptr ); + ~RiuWorkflowEditorWidget() override; + + void setWorkflow( RimWorkflow* workflow, RimWorkflowJob* job ); + RimWorkflow* workflow() const; + RimWorkflowJob* job() const; + void clear(); + void refresh(); + void updateJobState(); + void updateTaskInputValues(); + void updateTaskIssues(); + + RiuWorkflowGraphView* graphView() const; + RiuWorkflowTaskPalette* palette() const; + + // Edit operations of the graph context menus. Errors are shown in the status line. + bool applyEdit( const std::expected& result, const QMap& renames = {} ); + void addTask( const QString& taskId, const std::optional& scenePos = std::nullopt ); + void deleteItem( const RiuWorkflowGraphView::ItemRef& item ); + void renameTask( const QString& taskName ); + void setResultTask( const QString& taskName ); + void setOptionalInputConfigured( const QString& taskName, const QString& field, bool configured ); + void editTaskMap( const QString& taskName ); + void removeTaskMap( const QString& taskName ); + void renameCollectKey( const RiuWorkflowGraphView::ItemRef& edge ); + void moveCollectMember( const RiuWorkflowGraphView::ItemRef& edge, int delta ); + void connectPorts( const QString& from, const QString& output, const QString& to, const QString& input ); + + void showMessage( const QString& message ); + +signals: + void taskSelected( const QString& taskName ); + +private: + void showWorkflow( RiuWorkflowGraphView::ViewportPolicy policy ); + void showContextMenu( const RiuWorkflowGraphView::ItemRef& item, const QPoint& globalPos, const QPointF& scenePos ); + void appendAddTaskMenu( QMenu* menu, const QPointF& scenePos ); + void appendMapMenu( QMenu* menu, const QString& taskName ); + void appendCollectMenu( QMenu* menu, const RiuWorkflowGraphView::ItemRef& item ); + bool canEdit(); + void renameNodePositions( const QMap& renames ); + + const RimWorkflowDefinitionEdge* findEdge( const RiuWorkflowGraphView::ItemRef& item ) const; + + void appendOptionalInputsMenu( QMenu* menu, const QString& taskName ); + void updateActions(); + void updateStatus(); + void scheduleValidation(); + void onValidationFinished( RimWorkflow* workflow ); + +private: + caf::PdmPointer m_workflow; + caf::PdmPointer m_job; + QAction* m_saveAction; + QAction* m_saveAsAction; + QAction* m_undoAction; + QAction* m_redoAction; + QAction* m_duplicateAction; + QAction* m_fitAction; + QSplitter* m_splitter; + RiuWorkflowTaskPalette* m_palette; + RiuWorkflowGraphView* m_graphView; + QLabel* m_statusLabel; + RiuWorkflowValidationRunner* m_validationRunner; + QString m_message; +}; diff --git a/ApplicationLibCode/UserInterface/RiuWorkflowGraphLayout.cpp b/ApplicationLibCode/UserInterface/RiuWorkflowGraphLayout.cpp new file mode 100644 index 0000000000..9b48f838c0 --- /dev/null +++ b/ApplicationLibCode/UserInterface/RiuWorkflowGraphLayout.cpp @@ -0,0 +1,131 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#include "RiuWorkflowGraphLayout.h" + +#include + +#include +#include + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RiuWorkflowGraphLayout::Layout RiuWorkflowGraphLayout::computeLayers( const QStringList& nodes, + const std::vector>& edges ) +{ + QStringList uniqueNodes = nodes; + uniqueNodes.removeDuplicates(); + uniqueNodes.removeAll( QString() ); + const QSet nodeSet( uniqueNodes.begin(), uniqueNodes.end() ); + + QMap predecessors; + QMap successors; + QSet> seen; + for ( const auto& [from, to] : edges ) + { + if ( from == to || !nodeSet.contains( from ) || !nodeSet.contains( to ) || seen.contains( { from, to } ) ) continue; + seen.insert( { from, to } ); + predecessors[to].append( from ); + successors[from].append( to ); + } + + Layout layout; + + QMap remainingIncoming; + for ( const QString& node : uniqueNodes ) + remainingIncoming[node] = predecessors.value( node ).size(); + + QSet placed; + QStringList ready; + for ( const QString& node : uniqueNodes ) + { + if ( remainingIncoming[node] == 0 ) ready.append( node ); + } + + while ( placed.size() < uniqueNodes.size() ) + { + if ( ready.isEmpty() ) + { + // A cycle: release the first unplaced node (in input order) to break it + for ( const QString& node : uniqueNodes ) + { + if ( !placed.contains( node ) ) + { + ready.append( node ); + break; + } + } + } + + const QString node = ready.takeFirst(); + if ( placed.contains( node ) ) continue; + placed.insert( node ); + + int rank = 0; + for ( const QString& predecessor : predecessors.value( node ) ) + { + if ( placed.contains( predecessor ) ) rank = std::max( rank, layout.ranks.value( predecessor ) + 1 ); + } + layout.ranks[node] = rank; + + for ( const QString& successor : successors.value( node ) ) + { + if ( placed.contains( successor ) ) continue; + if ( --remainingIncoming[successor] == 0 ) ready.append( successor ); + } + } + + int layerCount = 0; + for ( int rank : layout.ranks ) + layerCount = std::max( layerCount, rank + 1 ); + layout.layers.resize( layerCount ); + for ( auto it = layout.ranks.cbegin(); it != layout.ranks.cend(); ++it ) + layout.layers[it.value()].append( it.key() ); + + // One barycenter pass from left to right, ties broken by name + QMap positions; + for ( QStringList& layer : layout.layers ) + { + std::vector> keyed; + for ( const QString& node : layer ) + { + double sum = 0.0; + int count = 0; + for ( const QString& predecessor : predecessors.value( node ) ) + { + if ( positions.contains( predecessor ) && layout.ranks.value( predecessor ) < layout.ranks.value( node ) ) + { + sum += positions.value( predecessor ); + ++count; + } + } + keyed.emplace_back( count > 0 ? sum / count : std::numeric_limits::max(), node ); + } + std::sort( keyed.begin(), keyed.end() ); + + layer.clear(); + for ( size_t i = 0; i < keyed.size(); ++i ) + { + layer.append( keyed[i].second ); + positions[keyed[i].second] = static_cast( i ); + } + } + + return layout; +} diff --git a/ApplicationLibCode/UserInterface/RiuWorkflowGraphLayout.h b/ApplicationLibCode/UserInterface/RiuWorkflowGraphLayout.h new file mode 100644 index 0000000000..ced8cb7044 --- /dev/null +++ b/ApplicationLibCode/UserInterface/RiuWorkflowGraphLayout.h @@ -0,0 +1,44 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include +#include + +#include +#include + +//================================================================================================== +/// Column assignment and ordering of workflow tasks for the graph view. Pure; positions in pixels +/// are left to the view, which knows the node sizes. +//================================================================================================== +namespace RiuWorkflowGraphLayout +{ +struct Layout +{ + QMap ranks; + std::vector layers; +}; + +// Longest-path ranks from a topological sort (Kahn), so the input does not need to be in +// topological order. Nodes on cycles are placed after their resolved predecessors. Within a +// column the nodes are ordered by the mean position of their predecessors, then by name. +Layout computeLayers( const QStringList& nodes, const std::vector>& edges ); +} // namespace RiuWorkflowGraphLayout diff --git a/ApplicationLibCode/UserInterface/RiuWorkflowGraphView.cpp b/ApplicationLibCode/UserInterface/RiuWorkflowGraphView.cpp index 995b3864a3..d1a04aa198 100644 --- a/ApplicationLibCode/UserInterface/RiuWorkflowGraphView.cpp +++ b/ApplicationLibCode/UserInterface/RiuWorkflowGraphView.cpp @@ -16,6 +16,10 @@ #include "RiuWorkflowGraphView.h" +#include "RiuWorkflowGraphLayout.h" + +#include +#include #include #include #include @@ -23,19 +27,26 @@ #include #include #include -#include +#include #include -#include +#include +#include #include #include #include +#include #include #include -#include +#include +#include +#include #include +#include #include #include +#include +#include #include namespace @@ -44,9 +55,41 @@ constexpr qreal nodeWidth = 240.0; constexpr qreal configWidth = 190.0; constexpr qreal portStep = 40.0; constexpr qreal portTop = 51.0; +constexpr qreal subtitleGap = 16.0; +constexpr qreal stackOffset = 5.0; constexpr qreal columnStep = 650.0; constexpr qreal configGap = 110.0; constexpr qreal rowGap = 55.0; +constexpr qreal portRadius = 5.0; +constexpr qreal portHitDist = 14.0; +constexpr int iconSize = 12; + +const QColor taskBorder( 60, 85, 115 ); +const QColor taskBackground( 226, 238, 250 ); +const QColor unknownBackground( 232, 232, 232 ); +const QColor configBorder( 140, 105, 45 ); +const QColor configBackground( 253, 244, 218 ); +const QColor configPortColor( 170, 110, 35 ); +const QColor inputPortColor( 75, 145, 95 ); +const QColor outputPortColor( 50, 115, 165 ); +const QColor missingPortColor( 190, 60, 60 ); +const QColor validTargetColor( 40, 170, 70 ); +const QColor invalidTargetColor( 175, 175, 175 ); +const QColor errorColor( 190, 45, 45 ); +const QColor warningColor( 200, 130, 20 ); +const QColor edgeColor( 75, 95, 115 ); +const QColor selectedEdgeColor( 30, 120, 220 ); +const QColor mapColor( 95, 80, 160 ); + +QString keyForField( const QString& field ) +{ + return field.isEmpty() ? "model" : "field:" + field; +} + +QString fieldForKey( const QString& key ) +{ + return key == "model" ? QString() : key.mid( 6 ); +} //-------------------------------------------------------------------------------------------------- /// @@ -62,95 +105,304 @@ QStringList fieldPorts( const QJsonArray& fields ) keys.sort(); return keys; } +} // namespace //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -QString inputPortKey( const QJsonObject& edge ) +namespace RiuWorkflowGraphViewItems { - const QString field = edge.value( "input" ).toString(); - return field.isEmpty() ? "model" : "field:" + field; -} - -//-------------------------------------------------------------------------------------------------- -/// -//-------------------------------------------------------------------------------------------------- -QString outputPortKey( const QJsonObject& edge ) -{ - const QString field = edge.value( "output" ).toString(); - return field.isEmpty() ? "model" : "field:" + field; -} - class GraphEdge; +//================================================================================================== +/// An icon drawn at the resolution it is shown at, so it stays sharp when the view is zoomed +//================================================================================================== +class IconItem : public QGraphicsItem +{ +public: + IconItem( const QIcon& icon, qreal size, QGraphicsItem* parent ); + + QRectF boundingRect() const override; + void paint( QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget ) override; + +private: + QIcon m_icon; + qreal m_size; +}; + +//================================================================================================== +/// How a port is drawn. Hollow: optional and not used. Missing: required and not provided. +//================================================================================================== +struct PortInfo +{ + enum class Style + { + Normal, + Hollow, + Missing + }; + + QString key; + QString label; + QString toolTip; + QString typeName{}; + QString iconResource{}; + Style style = Style::Normal; + bool collect = false; +}; + +//================================================================================================== +/// +//================================================================================================== +class PortItem : public QGraphicsEllipseItem +{ +public: + enum class Highlight + { + None, + Valid, + Invalid + }; + + PortItem( GraphNode* node, const PortInfo& info, bool output, bool config, const QPointF& center ); + + GraphNode* node() const { return m_node; } + QString key() const { return m_key; } + QString field() const { return fieldForKey( m_key ); } + bool isOutput() const { return m_output; } + bool isConfig() const { return m_config; } + void setHighlight( Highlight highlight, const QString& reason = {} ); + +private: + GraphNode* m_node; + QString m_key; + bool m_output; + bool m_config; + QPointF m_center; + QBrush m_brush; + QPen m_pen; + QString m_toolTip; +}; + +//================================================================================================== +/// +//================================================================================================== class GraphNode : public QGraphicsRectItem { public: - GraphNode( const QString& name, QStringList inputs, QStringList outputs, bool isConfig = false ); + GraphNode( const QString& name, + const std::vector& inputs, + const std::vector& outputs, + bool isConfig = false, + const QString& subtitle = {} ); - qreal height() const; - QPointF portPosition( const QString& key, bool output ) const; - void addEdge( GraphEdge* edge ); - QString name() const; - bool isConfig() const; - void setConfigValue( const QString& fieldName, const QString& value ); - void setTaskState( const QString& state, const QString& error = {} ); + qreal height() const; + QPointF portPosition( const QString& key, bool output ) const; + void addEdge( GraphEdge* edge ); + QString name() const; + bool isConfig() const; + std::vector ports() const; + void setConfigValue( const QString& fieldName, const QString& value ); + void setTaskState( const QString& state, const QString& error = {} ); + void setItemState( const QString& item, const QString& state, const QString& error = {} ); + void setSubtitleToolTip( const QString& toolTip ); + void setTitle( const QString& title, const QString& toolTip ); + void setIssues( const QJsonArray& issues ); + void setUnknownType( bool unknown ); protected: QVariant itemChange( GraphicsItemChange change, const QVariant& value ) override; private: - void addPorts( const QStringList& keys, bool output ); + void addPorts( const std::vector& ports, bool output ); + void applyStyle(); - QString m_name; - bool m_isConfig; - qreal m_width; - QMap m_inputs; - QMap m_outputs; - QMap m_configValues; - std::vector m_edges; - QGraphicsTextItem* m_stateLabel = nullptr; + QString m_name; + bool m_isConfig; + qreal m_width; + QMap m_inputs; + QMap m_outputs; + QMap m_configValues; + QMap m_configValueRight; // Where the value must end to stay clear of the type + std::vector m_edges; + QGraphicsTextItem* m_title = nullptr; + QGraphicsTextItem* m_stateLabel = nullptr; + QGraphicsTextItem* m_issueLabel = nullptr; + QGraphicsTextItem* m_subtitle = nullptr; + std::vector m_stack; + qreal m_portTop = portTop; + QMap m_itemStates; + QMap m_itemErrors; + QString m_state; + QString m_stateError; + QString m_baseToolTip; + QString m_issueText; + int m_errorCount = 0; + int m_warningCount = 0; + bool m_unknownType = false; }; +//================================================================================================== +/// +//================================================================================================== class GraphEdge : public QGraphicsPathItem { public: - GraphEdge( GraphNode* from, QString outputKey, GraphNode* to, QString inputKey, bool isConfig = false ); + GraphEdge( GraphNode* from, QString outputKey, GraphNode* to, QString inputKey, bool isConfig, bool selectable ); - void updatePath(); + void updatePath(); + QPainterPath shape() const override; + bool isConfig() const { return m_isConfig; } + GraphNode* fromNode() const { return m_from; } + GraphNode* toNode() const { return m_to; } + QString outputField() const { return fieldForKey( m_outputKey ); } + QString inputField() const { return fieldForKey( m_inputKey ); } + void setLabel( const QString& label, const QString& toolTip ); + +protected: + QVariant itemChange( GraphicsItemChange change, const QVariant& value ) override; private: + void applyPen(); + GraphNode* m_from; GraphNode* m_to; QString m_outputKey; QString m_inputKey; + bool m_isConfig; QGraphicsPolygonItem* m_arrow; + QGraphicsTextItem* m_label = nullptr; }; //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -GraphNode::GraphNode( const QString& name, QStringList inputs, QStringList outputs, bool isConfig ) +IconItem::IconItem( const QIcon& icon, qreal size, QGraphicsItem* parent ) + : QGraphicsItem( parent ) + , m_icon( icon ) + , m_size( size ) +{ +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QRectF IconItem::boundingRect() const +{ + return QRectF( 0, 0, m_size, m_size ); +} + +//-------------------------------------------------------------------------------------------------- +/// Renders the icon at the device pixel size of the item. SVG icons are rendered at that size, +/// bitmaps are scaled from the largest available size. +//-------------------------------------------------------------------------------------------------- +void IconItem::paint( QPainter* painter, const QStyleOptionGraphicsItem* /*option*/, QWidget* /*widget*/ ) +{ + const qreal zoom = QStyleOptionGraphicsItem::levelOfDetailFromTransform( painter->worldTransform() ); + const qreal pixelSize = m_size * zoom * ( painter->device() ? painter->device()->devicePixelRatioF() : 1.0 ); + const int extent = std::max( 1, qCeil( pixelSize ) ); + + const QPixmap pixmap = m_icon.pixmap( QSize( extent, extent ), 1.0 ); + painter->setRenderHint( QPainter::SmoothPixmapTransform ); + painter->drawPixmap( boundingRect(), pixmap, QRectF( pixmap.rect() ) ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +PortItem::PortItem( GraphNode* node, const PortInfo& info, bool output, bool config, const QPointF& center ) + : QGraphicsEllipseItem( center.x() - portRadius, center.y() - portRadius, 2 * portRadius, 2 * portRadius, node ) + , m_node( node ) + , m_key( info.key ) + , m_output( output ) + , m_config( config ) + , m_center( center ) + , m_toolTip( info.toolTip.isEmpty() ? info.label : info.toolTip ) +{ + const QColor color = config ? configPortColor : ( output ? outputPortColor : inputPortColor ); + switch ( info.style ) + { + case PortInfo::Style::Normal: + m_brush = color; + m_pen = Qt::NoPen; + break; + case PortInfo::Style::Hollow: + m_brush = QColor( 255, 255, 255 ); + m_pen = QPen( color, 1.5 ); + break; + case PortInfo::Style::Missing: + m_brush = QColor( 255, 255, 255 ); + m_pen = QPen( missingPortColor, 2.0 ); + break; + } + setHighlight( Highlight::None ); + setAcceptedMouseButtons( Qt::NoButton ); + setZValue( 2 ); + + // An outer ring marks inputs that collect several connections + if ( info.collect ) + { + const qreal ring = portRadius + 3.0; + auto* outer = new QGraphicsEllipseItem( center.x() - ring, center.y() - ring, 2 * ring, 2 * ring, node ); + outer->setPen( QPen( color, 1.2 ) ); + outer->setBrush( Qt::NoBrush ); + outer->setAcceptedMouseButtons( Qt::NoButton ); + outer->setZValue( 1.5 ); + } +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void PortItem::setHighlight( Highlight highlight, const QString& reason ) +{ + const qreal radius = highlight == Highlight::Valid ? portRadius * 1.8 : portRadius; + setRect( m_center.x() - radius, m_center.y() - radius, 2 * radius, 2 * radius ); + switch ( highlight ) + { + case Highlight::None: + setBrush( m_brush ); + setPen( m_pen ); + setToolTip( m_toolTip ); + break; + case Highlight::Valid: + setBrush( validTargetColor ); + setPen( QPen( validTargetColor.darker( 130 ), 1.5 ) ); + setToolTip( m_toolTip ); + break; + case Highlight::Invalid: + setBrush( invalidTargetColor ); + setPen( Qt::NoPen ); + setToolTip( reason.isEmpty() ? m_toolTip : m_toolTip + "\n\nCannot connect: " + reason ); + break; + } +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +GraphNode::GraphNode( const QString& name, + const std::vector& inputs, + const std::vector& outputs, + bool isConfig, + const QString& subtitle ) : m_name( name ) , m_isConfig( isConfig ) , m_width( isConfig ? configWidth : nodeWidth ) + , m_portTop( subtitle.isEmpty() ? portTop : portTop + subtitleGap ) + , m_baseToolTip( name ) { - setRect( 0, 0, m_width, portTop + portStep * std::max( { qsizetype( 1 ), inputs.size(), outputs.size() } ) + 12 ); - setPen( QPen( isConfig ? QColor( 140, 105, 45 ) : QColor( 60, 85, 115 ) ) ); - setBrush( isConfig ? QColor( 253, 244, 218 ) : QColor( 226, 238, 250 ) ); + const qsizetype rows = std::max( { qsizetype( 1 ), qsizetype( inputs.size() ), qsizetype( outputs.size() ) } ); + setRect( 0, 0, m_width, m_portTop + portStep * rows + 12 ); setFlags( ItemIsMovable | ItemIsSelectable | ItemSendsGeometryChanges ); setZValue( 1 ); - setToolTip( name ); + m_title = new QGraphicsTextItem( this ); QFont titleFont; titleFont.setBold( true ); - const QString titleText = isConfig ? "Config: " + name : name; - auto* title = - new QGraphicsTextItem( QFontMetrics( titleFont ).elidedText( titleText, Qt::ElideRight, m_width - ( isConfig ? 20 : 100 ) ), this ); - title->setFont( titleFont ); - title->setToolTip( titleText ); - title->setPos( 10, 4 ); - title->setAcceptedMouseButtons( Qt::NoButton ); + m_title->setFont( titleFont ); + m_title->setPos( 10, 4 ); + m_title->setAcceptedMouseButtons( Qt::NoButton ); if ( !isConfig ) { @@ -161,10 +413,38 @@ GraphNode::GraphNode( const QString& name, QStringList inputs, QStringList outpu m_stateLabel->setFont( font ); m_stateLabel->setPos( m_width - 91, 5 ); m_stateLabel->setAcceptedMouseButtons( Qt::NoButton ); + + m_issueLabel = new QGraphicsTextItem( this ); + m_issueLabel->setFont( font ); + m_issueLabel->setPos( 8, rect().height() - 2 ); + m_issueLabel->setAcceptedMouseButtons( Qt::NoButton ); + } + + // A mapped task runs once per item: drawn as a stack of boxes with a line saying what it maps over + if ( !subtitle.isEmpty() ) + { + for ( int depth = 2; depth >= 1; --depth ) + { + auto* card = new QGraphicsRectItem( rect().translated( depth * stackOffset, depth * stackOffset ), this ); + card->setFlag( ItemStacksBehindParent ); + card->setAcceptedMouseButtons( Qt::NoButton ); + m_stack.push_back( card ); + } + + m_subtitle = new QGraphicsTextItem( this ); + QFont font; + font.setPointSize( 8 ); + font.setItalic( true ); + m_subtitle->setFont( font ); + m_subtitle->setDefaultTextColor( mapColor ); + m_subtitle->setPlainText( QFontMetrics( font ).elidedText( subtitle, Qt::ElideRight, m_width - 20 ) ); + m_subtitle->setPos( 10, 24 ); + m_subtitle->setAcceptedMouseButtons( Qt::NoButton ); } addPorts( inputs, false ); addPorts( outputs, true ); + setTitle( isConfig ? "Config: " + name : name, {} ); } //-------------------------------------------------------------------------------------------------- @@ -178,31 +458,79 @@ qreal GraphNode::height() const //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -void GraphNode::addPorts( const QStringList& keys, bool output ) +void GraphNode::setTitle( const QString& title, const QString& toolTip ) { - auto& ports = output ? m_outputs : m_inputs; - for ( int row = 0; row < keys.size(); ++row ) - { - const QString& key = keys[row]; - const qreal x = output ? m_width : 0.0; - const qreal y = portTop + row * portStep; - auto* port = new QGraphicsEllipseItem( x - 5, y - 5, 10, 10, this ); - const QString label = key == "model" ? ( output ? "output" : "input" ) : key.mid( 6 ); - port->setBrush( m_isConfig ? QColor( 170, 110, 35 ) : ( output ? QColor( 50, 115, 165 ) : QColor( 75, 145, 95 ) ) ); - port->setPen( Qt::NoPen ); - port->setToolTip( label ); - port->setAcceptedMouseButtons( Qt::NoButton ); - ports.insert( key, port ); + m_title->setPlainText( QFontMetrics( m_title->font() ).elidedText( title, Qt::ElideRight, m_width - ( m_isConfig ? 20 : 100 ) ) ); + m_title->setToolTip( title ); + if ( !toolTip.isEmpty() ) m_baseToolTip = toolTip; + applyStyle(); +} - QFont font; - font.setPointSize( 9 ); - const QString display = QFontMetrics( font ).elidedText( label, Qt::ElideRight, m_isConfig ? m_width - 24 : m_width / 2 - 18 ); - auto* text = new QGraphicsTextItem( display, this ); - text->setFont( font ); - text->setToolTip( label ); - text->setDefaultTextColor( QColor( 45, 65, 80 ) ); - text->setPos( output ? m_width - text->boundingRect().width() - 12 : 10, y - ( m_isConfig || !output ? 20 : 13 ) ); - text->setAcceptedMouseButtons( Qt::NoButton ); +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void GraphNode::addPorts( const std::vector& ports, bool output ) +{ + auto& portMap = output ? m_outputs : m_inputs; + for ( size_t row = 0; row < ports.size(); ++row ) + { + const PortInfo& info = ports[row]; + const qreal x = output ? m_width : 0.0; + const qreal y = m_portTop + row * portStep; + auto* port = new PortItem( this, info, output, m_isConfig, QPointF( x, y ) ); + portMap.insert( info.key, port ); + + const QString toolTip = info.toolTip.isEmpty() ? info.label : info.toolTip; + const qreal textWidth = m_isConfig ? m_width - 24 : m_width / 2 - 18; + const bool showType = !info.typeName.isEmpty(); + auto addText = [&]( const QString& content, + const QFont& font, + const QColor& color, + qreal top, + qreal indent = 0.0, + qreal maxWidth = std::numeric_limits::max() ) + { + const qreal width = std::min( textWidth, maxWidth ) - indent; + auto* text = new QGraphicsTextItem( QFontMetrics( font ).elidedText( content, Qt::ElideRight, width ), this ); + text->setFont( font ); + text->setToolTip( toolTip ); + text->setDefaultTextColor( color ); + text->setPos( output ? m_width - text->boundingRect().width() - 12 : 10 + indent, top ); + text->setAcceptedMouseButtons( Qt::NoButton ); + return text; + }; + + QFont labelFont; + labelFont.setPointSize( 9 ); + const QColor labelColor = info.style == PortInfo::Style::Missing ? missingPortColor : QColor( 45, 65, 80 ); + addText( info.label, labelFont, labelColor, y - ( m_isConfig || !output || showType ? 20 : 13 ) ); + + // The type goes on a second, smaller line below the label + if ( showType ) + { + QFont typeFont; + typeFont.setPointSize( 7 ); + typeFont.setItalic( true ); + const QIcon icon( info.iconResource ); + const bool showIcon = !info.iconResource.isEmpty() && !icon.isNull(); + // Config boxes show the value on the same line, so the type gets at most half of it + const qreal maxTypeWidth = m_isConfig ? textWidth / 2 : std::numeric_limits::max(); + auto* typeText = addText( info.typeName, typeFont, QColor( 110, 120, 135 ), y - 3, showIcon ? iconSize + 2 : 0.0, maxTypeWidth ); + qreal typeLeft = typeText->mapRectToParent( typeText->boundingRect() ).left(); + + // The icon of the ResInsight object goes in front of the type + if ( showIcon ) + { + const QRectF textRect = typeText->mapRectToParent( typeText->boundingRect() ); + const qreal iconX = output ? textRect.left() - iconSize - 1 : 10 + 3; + auto* iconItem = new IconItem( icon, iconSize, this ); + iconItem->setPos( iconX, textRect.center().y() - iconSize / 2.0 ); + iconItem->setToolTip( toolTip ); + iconItem->setAcceptedMouseButtons( Qt::NoButton ); + typeLeft = iconX; + } + if ( m_isConfig && output ) m_configValueRight.insert( fieldForKey( info.key ), typeLeft ); + } } } @@ -240,40 +568,188 @@ bool GraphNode::isConfig() const return m_isConfig; } +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +std::vector GraphNode::ports() const +{ + std::vector result; + for ( PortItem* port : m_inputs ) + result.push_back( port ); + for ( PortItem* port : m_outputs ) + result.push_back( port ); + return result; +} + //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- void GraphNode::setTaskState( const QString& state, const QString& error ) { if ( m_isConfig ) return; + m_state = state; + m_stateError = error; + if ( state.isEmpty() ) + { + m_itemStates.clear(); + m_itemErrors.clear(); + } + applyStyle(); +} - QColor border( 60, 85, 115 ); - QColor background( 226, 238, 250 ); - if ( state == "running" ) +//-------------------------------------------------------------------------------------------------- +/// The state of one item of a mapped task +//-------------------------------------------------------------------------------------------------- +void GraphNode::setItemState( const QString& item, const QString& state, const QString& error ) +{ + if ( m_isConfig ) return; + m_itemStates[item] = state; + if ( error.isEmpty() ) + m_itemErrors.remove( item ); + else + m_itemErrors[item] = error; + applyStyle(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void GraphNode::setSubtitleToolTip( const QString& toolTip ) +{ + if ( m_subtitle ) m_subtitle->setToolTip( toolTip ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void GraphNode::setIssues( const QJsonArray& issues ) +{ + if ( m_isConfig ) return; + + m_errorCount = 0; + m_warningCount = 0; + QStringList lines; + for ( const QJsonValue& value : issues ) + { + const QJsonObject issue = value.toObject(); + const bool isError = issue.value( "severity" ).toString() != "warning"; + ( isError ? m_errorCount : m_warningCount )++; + lines.append( QString( "%1 %2" ).arg( isError ? "Error:" : "Warning:", issue.value( "message" ).toString() ) ); + } + m_issueText = lines.join( "\n" ); + applyStyle(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void GraphNode::setUnknownType( bool unknown ) +{ + m_unknownType = unknown; + applyStyle(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void GraphNode::applyStyle() +{ + if ( m_isConfig ) + { + setPen( QPen( configBorder ) ); + setBrush( configBackground ); + setToolTip( m_baseToolTip ); + return; + } + + QColor border = taskBorder; + QColor background = m_unknownType ? unknownBackground : taskBackground; + qreal width = 1.0; + auto style = Qt::SolidLine; + if ( m_state == "running" ) { border = QColor( 158, 112, 16 ); background = QColor( 255, 241, 189 ); } - else if ( state == "completed" ) + else if ( m_state == "completed" ) { border = QColor( 42, 120, 65 ); background = QColor( 215, 242, 222 ); } - else if ( state == "failed" ) + else if ( m_state == "failed" ) { border = QColor( 165, 50, 50 ); background = QColor( 253, 225, 225 ); } - else if ( state == "interrupted" ) + else if ( m_state == "interrupted" ) { border = QColor( 140, 85, 50 ); background = QColor( 245, 228, 210 ); } - setPen( QPen( border, state.isEmpty() ? 1 : 2 ) ); + else if ( m_errorCount > 0 || m_warningCount > 0 ) + { + border = m_errorCount > 0 ? errorColor : warningColor; + style = Qt::DashLine; + } + if ( !m_state.isEmpty() || m_errorCount > 0 || m_warningCount > 0 ) width = 2.0; + if ( isSelected() ) width += 1.0; + + QPen pen( border, width ); + pen.setStyle( style ); + setPen( pen ); setBrush( background ); - m_stateLabel->setDefaultTextColor( border ); - m_stateLabel->setPlainText( state.isEmpty() ? "" : state.at( 0 ).toUpper() + state.mid( 1 ) ); - setToolTip( error.isEmpty() ? m_name : m_name + "\n" + error ); + for ( QGraphicsRectItem* card : m_stack ) + { + card->setPen( QPen( border, 1.0 ) ); + card->setBrush( background.darker( 104 ) ); + } + + // Item progress of a mapped task, such as "Running 3✓ 1✗" + int done = 0; + int failed = 0; + for ( const QString& itemState : m_itemStates ) + { + if ( itemState == "completed" ) ++done; + if ( itemState == "failed" ) ++failed; + } + if ( m_stateLabel ) + { + QString text = m_state.isEmpty() ? "" : m_state.at( 0 ).toUpper() + m_state.mid( 1 ); + if ( !m_itemStates.isEmpty() ) + { + text += QString( " %1/%2" ).arg( done ).arg( m_itemStates.size() ); + if ( failed > 0 ) text += QString::fromUtf8( " ✗%1" ).arg( failed ); + } + m_stateLabel->setDefaultTextColor( border ); + m_stateLabel->setPlainText( text ); + m_stateLabel->setPos( m_width - m_stateLabel->boundingRect().width() - 6, 5 ); + } + + if ( m_issueLabel ) + { + QStringList badge; + if ( m_errorCount > 0 ) badge << QString( "%1 error%2" ).arg( m_errorCount ).arg( m_errorCount > 1 ? "s" : "" ); + if ( m_warningCount > 0 ) badge << QString( "%1 warning%2" ).arg( m_warningCount ).arg( m_warningCount > 1 ? "s" : "" ); + m_issueLabel->setDefaultTextColor( m_errorCount > 0 ? errorColor : warningColor ); + m_issueLabel->setPlainText( badge.isEmpty() ? "" : QString::fromUtf8( "\u26a0 " ) + badge.join( ", " ) ); + m_issueLabel->setToolTip( m_issueText ); + } + + QStringList toolTip{ m_baseToolTip }; + if ( !m_stateError.isEmpty() ) toolTip << m_stateError; + if ( !m_itemStates.isEmpty() ) + { + QStringList items; + for ( auto it = m_itemStates.cbegin(); it != m_itemStates.cend(); ++it ) + { + QString line = QString( "%1: %2" ).arg( it.key(), it.value() ); + if ( m_itemErrors.contains( it.key() ) ) line += " - " + m_itemErrors.value( it.key() ); + items << line; + } + toolTip << "Items:\n" + items.join( "\n" ); + } + if ( !m_issueText.isEmpty() ) toolTip << m_issueText; + setToolTip( toolTip.join( "\n\n" ) ); } //-------------------------------------------------------------------------------------------------- @@ -298,7 +774,11 @@ void GraphNode::setConfigValue( const QString& fieldName, const QString& value ) m_configValues.insert( fieldName, text ); } - text->setPlainText( QFontMetrics( text->font() ).elidedText( value, Qt::ElideRight, m_width - 24 ) ); + // The text item adds a margin on both sides of the text + const qreal margin = 2 * text->document()->documentMargin(); + const qreal right = m_configValueRight.value( fieldName, m_width - 14 ) - 6; + const qreal available = std::max( 0.0, right - text->pos().x() - margin ); + text->setPlainText( QFontMetrics( text->font() ).elidedText( value, Qt::ElideRight, static_cast( available ) ) ); text->setToolTip( fieldName + ": " + value ); } @@ -313,30 +793,73 @@ QVariant GraphNode::itemChange( GraphicsItemChange change, const QVariant& value edge->updatePath(); if ( scene() ) scene()->setSceneRect( scene()->sceneRect().united( scene()->itemsBoundingRect().adjusted( -50, -50, 50, 50 ) ) ); } + else if ( change == ItemSelectedHasChanged && m_title ) + { + applyStyle(); + } return QGraphicsRectItem::itemChange( change, value ); } //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -GraphEdge::GraphEdge( GraphNode* from, QString outputKey, GraphNode* to, QString inputKey, bool isConfig ) +GraphEdge::GraphEdge( GraphNode* from, QString outputKey, GraphNode* to, QString inputKey, bool isConfig, bool selectable ) : m_from( from ) , m_to( to ) , m_outputKey( std::move( outputKey ) ) , m_inputKey( std::move( inputKey ) ) + , m_isConfig( isConfig ) , m_arrow( new QGraphicsPolygonItem( this ) ) { - QPen pen( isConfig ? QColor( 170, 110, 35 ) : QColor( 75, 95, 115 ), 1.7 ); - if ( isConfig ) pen.setStyle( Qt::DashLine ); - setPen( pen ); setZValue( -1 ); + if ( selectable ) + { + setFlag( ItemIsSelectable ); + setToolTip( QString( "%1.%2 \u2192 %3.%4" ) + .arg( from->name(), + outputField().isEmpty() ? "output" : outputField(), + to->name(), + inputField().isEmpty() ? "input" : inputField() ) ); + } m_arrow->setPen( Qt::NoPen ); - m_arrow->setBrush( pen.color() ); + applyPen(); m_from->addEdge( this ); m_to->addEdge( this ); updatePath(); } +//-------------------------------------------------------------------------------------------------- +/// A label near the target, such as the index or key of a collected member +//-------------------------------------------------------------------------------------------------- +void GraphEdge::setLabel( const QString& label, const QString& toolTip ) +{ + if ( label.isEmpty() ) return; + if ( !m_label ) + { + m_label = new QGraphicsTextItem( this ); + QFont font; + font.setPointSize( 8 ); + m_label->setFont( font ); + m_label->setDefaultTextColor( edgeColor ); + m_label->setAcceptedMouseButtons( Qt::NoButton ); + } + m_label->setPlainText( label ); + if ( !toolTip.isEmpty() ) m_label->setToolTip( toolTip ); + updatePath(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void GraphEdge::applyPen() +{ + const QColor color = isSelected() ? selectedEdgeColor : ( m_isConfig ? configPortColor : edgeColor ); + QPen pen( color, isSelected() ? 3.0 : 1.7 ); + if ( m_isConfig ) pen.setStyle( Qt::DashLine ); + setPen( pen ); + m_arrow->setBrush( color ); +} + //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- @@ -349,6 +872,151 @@ void GraphEdge::updatePath() path.cubicTo( start + QPointF( bend, 0 ), end - QPointF( bend, 0 ), end ); setPath( path ); m_arrow->setPolygon( QPolygonF{ end, end + QPointF( -9, -5 ), end + QPointF( -9, 5 ) } ); + + // Place the label on the curve shortly before the target + if ( m_label ) + { + const QPointF anchor = path.pointAtPercent( 0.85 ); + const QRectF bounds = m_label->boundingRect(); + m_label->setPos( anchor.x() - bounds.width() / 2.0, anchor.y() - bounds.height() ); + } +} + +//-------------------------------------------------------------------------------------------------- +/// A wide stroke, so the thin curve is easy to click +//-------------------------------------------------------------------------------------------------- +QPainterPath GraphEdge::shape() const +{ + QPainterPathStroker stroker; + stroker.setWidth( 12 ); + return stroker.createStroke( path() ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QVariant GraphEdge::itemChange( GraphicsItemChange change, const QVariant& value ) +{ + if ( change == ItemSelectedHasChanged ) applyPen(); + return QGraphicsPathItem::itemChange( change, value ); +} +} // namespace RiuWorkflowGraphViewItems + +using namespace RiuWorkflowGraphViewItems; + +namespace +{ +//-------------------------------------------------------------------------------------------------- +/// A port described by the graph JSON, with the type and description as the first tooltip lines +//-------------------------------------------------------------------------------------------------- +PortInfo portFromJson( const QJsonObject& port, const QString& wholeLabel, QStringList* toolTip ) +{ + const QString field = port.value( "name" ).toString(); + PortInfo info{ .key = keyForField( field ), + .label = field.isEmpty() ? wholeLabel : field, + .toolTip = {}, + .typeName = port.value( "type" ).toString(), + .iconResource = port.value( "icon" ).toString() }; + *toolTip = { QString( "%1: %2" ).arg( info.label, port.value( "type" ).toString( "any" ) ) }; + if ( !port.value( "description" ).toString().isEmpty() ) *toolTip << port.value( "description" ).toString(); + return info; +} + +//-------------------------------------------------------------------------------------------------- +/// The port of the whole input or output model of a task +//-------------------------------------------------------------------------------------------------- +PortInfo wholeModelPort( const QJsonObject& task, bool output ) +{ + const QString prefix = output ? "output" : "input"; + return PortInfo{ .key = "model", + .label = prefix, + .toolTip = {}, + .typeName = task.value( prefix + "_types" ).toObject().value( "" ).toString(), + .iconResource = task.value( prefix + "_icons" ).toObject().value( "" ).toString() }; +} + +//-------------------------------------------------------------------------------------------------- +/// Ports of a task in edit mode: every input and output the task type declares +//-------------------------------------------------------------------------------------------------- +std::pair, std::vector> editModePorts( const QJsonObject& task, const QJsonArray& edges ) +{ + const QString name = task.value( "name" ).toString(); + + std::vector inputs; + bool hasWholeInput = false; + for ( const QJsonValue& value : task.value( "input_ports" ).toArray() ) + { + const QJsonObject port = value.toObject(); + const QString field = port.value( "name" ).toString(); + const bool required = port.value( "required" ).toBool(); + const bool provided = port.value( "wired" ).toBool() || port.value( "configured" ).toBool() || port.value( "covered" ).toBool(); + + // The whole input model is only shown when it is wired, or when it is the only way into the task + if ( field.isEmpty() ) + { + if ( !port.value( "wired" ).toBool() && !task.value( "whole_input" ).toBool() ) continue; + hasWholeInput = true; + } + + QStringList toolTip; + PortInfo info = portFromJson( port, "input", &toolTip ); + toolTip << ( required ? "Required" : "Optional" ); + if ( port.value( "configured" ).toBool() ) toolTip << "Set in the job configuration"; + if ( port.value( "covered" ).toBool() ) toolTip << "Provided by a whole-output connection"; + if ( !port.value( "collect" ).toString().isEmpty() ) + { + info.collect = true; + toolTip << ( port.value( "collect" ).toString() == "dict" ? "Connect several outputs to collect them, each under its own key" + : "Connect several outputs to collect them into a list" ); + } + info.toolTip = toolTip.join( "\n" ); + if ( port.value( "map_over" ).toBool() ) + { + info.toolTip = QString( "%1: %2\n%3\nThe task runs once per item; set the items in the job configuration" ) + .arg( info.label, info.typeName, port.value( "description" ).toString() ); + } + if ( !provided && !field.isEmpty() ) info.style = required ? PortInfo::Style::Missing : PortInfo::Style::Hollow; + inputs.push_back( info ); + } + for ( const QJsonValue& value : edges ) + { + const QJsonObject edge = value.toObject(); + if ( edge.value( "to" ).toString() == name && edge.value( "input" ).toString().isEmpty() && !hasWholeInput ) + { + inputs.insert( inputs.begin(), wholeModelPort( task, false ) ); + hasWholeInput = true; + } + } + + std::vector outputs; + for ( const QJsonValue& value : task.value( "output_ports" ).toArray() ) + { + QStringList toolTip; + PortInfo info = portFromJson( value.toObject(), "output", &toolTip ); + info.toolTip = toolTip.join( "\n" ); + outputs.push_back( info ); + } + if ( outputs.empty() ) outputs.push_back( wholeModelPort( task, true ) ); + return { inputs, outputs }; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +std::vector portInfos( const QStringList& keys, bool output, const QJsonObject& types, const QJsonObject& icons ) +{ + std::vector result; + for ( const QString& key : keys ) + { + const QString typeName = types.value( fieldForKey( key ) ).toString(); + const QString label = key == "model" ? ( output ? "output" : "input" ) : fieldForKey( key ); + result.push_back( PortInfo{ .key = key, + .label = label, + .toolTip = typeName.isEmpty() ? label : QString( "%1: %2" ).arg( label, typeName ), + .typeName = typeName, + .iconResource = icons.value( fieldForKey( key ) ).toString() } ); + } + return result; } } // namespace @@ -363,39 +1031,136 @@ RiuWorkflowGraphView::RiuWorkflowGraphView( QWidget* parent ) setRenderHint( QPainter::Antialiasing ); setDragMode( QGraphicsView::ScrollHandDrag ); setTransformationAnchor( QGraphicsView::AnchorUnderMouse ); + setAcceptDrops( true ); + setFocusPolicy( Qt::StrongFocus ); + + connect( m_scene, &QGraphicsScene::selectionChanged, this, &RiuWorkflowGraphView::onSelectionChanged ); } //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -void RiuWorkflowGraphView::showGraph( const QJsonObject& graph, const QString& error ) +RiuWorkflowGraphView::~RiuWorkflowGraphView() = default; + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::setEditable( bool editable ) { + m_editable = editable; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RiuWorkflowGraphView::isEditable() const +{ + return m_editable; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::setConnectionValidator( const ConnectionValidator& validator ) +{ + m_validator = validator; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::setPendingNodePosition( const QString& taskName, const QPointF& scenePos ) +{ + m_pendingPositions[taskName] = scenePos; +} + +//-------------------------------------------------------------------------------------------------- +/// Keep the position of a task the user has placed when it is renamed +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::renameNodePosition( const QString& oldName, const QString& newName ) +{ + rememberPositions(); + for ( const QString& prefix : { QString( "task:" ), QString( "config:" ) } ) + { + if ( m_positions.contains( prefix + oldName ) ) m_positions[prefix + newName] = m_positions.take( prefix + oldName ); + } + if ( m_lastSelectedTask == oldName ) m_lastSelectedTask = newName; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::rememberPositions() +{ + for ( auto it = m_taskNodes.cbegin(); it != m_taskNodes.cend(); ++it ) + m_positions["task:" + it.key()] = it.value()->pos(); + for ( auto it = m_configNodes.cbegin(); it != m_configNodes.cend(); ++it ) + m_positions["config:" + it.key()] = it.value()->pos(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::fitGraph() +{ + m_fitOnResize = true; + if ( !m_scene->sceneRect().isEmpty() ) fitInView( m_scene->sceneRect(), Qt::KeepAspectRatio ); +} + +//-------------------------------------------------------------------------------------------------- +/// Rebuild the scene. With ViewportPolicy::Keep, tasks keep their positions and the view keeps +/// its zoom and scroll position, so edits do not make the graph jump. +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::showGraph( const QJsonObject& graph, const QString& error, ViewportPolicy policy ) +{ + finishConnectionDrag( false ); + + const QTransform oldTransform = transform(); + const QPointF oldCenter = mapToScene( viewport()->rect().center() ); + const bool keepViewport = policy == ViewportPolicy::Keep && !m_taskNodes.isEmpty() && !m_fitOnResize; + const QString selectedTask = m_lastSelectedTask; + if ( policy == ViewportPolicy::Keep ) + rememberPositions(); + else + m_positions.clear(); + + m_rebuilding = true; + m_taskNodes.clear(); + m_configNodes.clear(); m_runStatusLabel = nullptr; m_scene->clear(); - m_fitOnResize = true; + if ( policy == ViewportPolicy::Fit ) m_fitOnResize = true; if ( !error.isEmpty() || graph.value( "tasks" ).toArray().isEmpty() ) { - m_scene->addText( error.isEmpty() ? "No tasks in this workflow" : error ); - fitInView( m_scene->itemsBoundingRect().adjusted( -30, -30, 30, 30 ), Qt::KeepAspectRatio ); + QString message = error; + if ( message.isEmpty() ) + message = m_editable ? "No tasks yet.\nDrag a task from the palette, or right-click to add one." : "No tasks in this workflow"; + m_scene->addText( message ); + m_scene->setSceneRect( m_scene->itemsBoundingRect().adjusted( -30, -30, 30, 30 ) ); + fitInView( m_scene->sceneRect(), Qt::KeepAspectRatio ); + m_rebuilding = false; + m_pendingPositions.clear(); return; } - const QJsonArray tasks = graph.value( "tasks" ).toArray(); - const QJsonArray edges = graph.value( "edges" ).toArray(); - QMap ranks; - QMap inputs; - QMap outputs; - QMap declaredOutputs; - QMap configFields; + const bool editMode = m_editable && graph.value( "editable" ).toBool(); + const QJsonArray tasks = graph.value( "tasks" ).toArray(); + const QJsonArray edges = graph.value( "edges" ).toArray(); + const QString result = graph.value( "result_task" ).toString(); + + QStringList taskNames; + std::vector> layoutEdges; + QMap taskData; + QMap configFields; for ( const QJsonValue& task : tasks ) { const QJsonObject data = task.toObject(); const QString name = data.value( "name" ).toString(); - if ( name.isEmpty() || ranks.contains( name ) ) continue; - ranks.insert( name, 0 ); - inputs.insert( name, fieldPorts( data.value( "inputs" ).toArray() ) ); - declaredOutputs.insert( name, fieldPorts( data.value( "outputs" ).toArray() ) ); + if ( name.isEmpty() || taskData.contains( name ) ) continue; + taskNames.append( name ); + taskData.insert( name, data ); + QStringList configured; for ( const QJsonValue& field : data.value( "config_fields" ).toArray() ) { @@ -403,90 +1168,133 @@ void RiuWorkflowGraphView::showGraph( const QJsonObject& graph, const QString& e if ( !fieldName.isEmpty() ) configured.append( "field:" + fieldName ); } configured.removeDuplicates(); - configured.sort(); configFields.insert( name, configured ); - inputs[name].append( configured ); - } - - // taskmaestro describes tasks in topological order. - for ( const QJsonValue& task : tasks ) - { - const QString name = task.toObject().value( "name" ).toString(); - if ( !ranks.contains( name ) ) continue; - for ( const QJsonValue& value : edges ) - { - const QJsonObject edge = value.toObject(); - if ( edge.value( "to" ).toString() == name && ranks.contains( edge.value( "from" ).toString() ) ) - { - ranks[name] = std::max( ranks[name], ranks.value( edge.value( "from" ).toString() ) + 1 ); - } - } } + // Read-only graphs only show the ports in use, plus the declared outputs of end tasks + QMap usedInputs; + QMap usedOutputs; for ( const QJsonValue& value : edges ) { const QJsonObject edge = value.toObject(); const QString from = edge.value( "from" ).toString(); const QString to = edge.value( "to" ).toString(); - if ( !ranks.contains( from ) || !ranks.contains( to ) || from == to ) continue; - inputs[to].append( inputPortKey( edge ) ); - outputs[from].append( outputPortKey( edge ) ); + layoutEdges.emplace_back( from, to ); + if ( !taskData.contains( from ) || !taskData.contains( to ) || from == to ) continue; + usedInputs[to].append( keyForField( edge.value( "input" ).toString() ) ); + usedOutputs[from].append( keyForField( edge.value( "output" ).toString() ) ); } - QMap layers; - QMap nodes; - QMap configNodes; - for ( auto it = ranks.cbegin(); it != ranks.cend(); ++it ) + for ( const QString& name : taskNames ) { - layers[it.value()].append( it.key() ); - QStringList in = inputs.value( it.key() ); - QStringList out = outputs.value( it.key() ); - // Non-terminal tasks only need ports used by outgoing connections. - // Keep declared fields visible on terminal tasks as workflow results. - if ( out.isEmpty() ) out = declaredOutputs.value( it.key() ); - if ( out.isEmpty() ) out.append( "model" ); - in.removeDuplicates(); - out.removeDuplicates(); - in.sort(); - out.sort(); - auto* node = new GraphNode( it.key(), in, out ); - m_scene->addItem( node ); - nodes.insert( it.key(), node ); - if ( !configFields.value( it.key() ).isEmpty() ) + const QJsonObject data = taskData.value( name ); + + std::vector inputs; + std::vector outputs; + if ( editMode ) { - auto* configNode = new GraphNode( it.key(), {}, configFields.value( it.key() ), true ); + std::tie( inputs, outputs ) = editModePorts( data, edges ); + } + else + { + QStringList in = fieldPorts( data.value( "inputs" ).toArray() ) + usedInputs.value( name ); + in.append( configFields.value( name ) ); + QStringList out = usedOutputs.value( name ); + if ( out.isEmpty() ) out = fieldPorts( data.value( "outputs" ).toArray() ); + if ( out.isEmpty() ) out.append( "model" ); + in.removeDuplicates(); + out.removeDuplicates(); + in.sort(); + out.sort(); + inputs = portInfos( in, false, data.value( "input_types" ).toObject(), data.value( "input_icons" ).toObject() ); + outputs = portInfos( out, true, data.value( "output_types" ).toObject(), data.value( "output_icons" ).toObject() ); + } + + // Config fields follow the order of the task's inputs, so their connections do not cross + QStringList& configured = configFields[name]; + auto inputIndex = [&inputs]( const QString& key ) + { + const auto it = std::find_if( inputs.begin(), inputs.end(), [&key]( const PortInfo& port ) { return port.key == key; } ); + return std::distance( inputs.begin(), it ); + }; + std::stable_sort( configured.begin(), + configured.end(), + [&inputIndex]( const QString& a, const QString& b ) { return inputIndex( a ) < inputIndex( b ); } ); + + const QJsonObject map = data.value( "map" ).toObject(); + QString subtitle; + if ( !map.isEmpty() ) subtitle = QString::fromUtf8( "⟳ map over %1" ).arg( map.value( "over" ).toString() ); + auto* node = new GraphNode( name, inputs, outputs, false, subtitle ); + if ( !map.isEmpty() ) + { + QStringList mapToolTip{ + QString( "Runs once per item of '%1' (%2)" ).arg( map.value( "over" ).toString(), map.value( "type" ).toString() ) }; + if ( !map.value( "key_as" ).toString().isEmpty() ) mapToolTip << QString( "Key → %1" ).arg( map.value( "key_as" ).toString() ); + if ( !map.value( "value_as" ).toString().isEmpty() ) + mapToolTip << QString( "Value → %1" ).arg( map.value( "value_as" ).toString() ); + mapToolTip << ( map.value( "error_mode" ).toString() == "collect_all" + ? "Every item runs; the task fails afterwards if any item failed" + : "The first failed item stops the task" ); + mapToolTip << "The output is a dict with one entry per item"; + node->setSubtitleToolTip( mapToolTip.join( "\n" ) ); + } + const QString taskId = data.value( "task_id" ).toString(); + const QString description = data.value( "description" ).toString(); + QStringList toolTip{ name }; + if ( !taskId.isEmpty() ) toolTip << taskId; + if ( !description.isEmpty() ) toolTip << description; + const bool unknownType = data.contains( "known_type" ) && !data.value( "known_type" ).toBool(); + if ( name == result ) toolTip << "Result task: its output is the result of the workflow"; + if ( unknownType ) toolTip << "Unknown task type: the package providing it is not installed"; + node->setTitle( name == result ? QString::fromUtf8( "\u2605 " ) + name : name, toolTip.join( "\n" ) ); + node->setUnknownType( unknownType ); + node->setIssues( data.value( "issues" ).toArray() ); + m_scene->addItem( node ); + m_taskNodes.insert( name, node ); + + if ( !configFields.value( name ).isEmpty() ) + { + auto* configNode = new GraphNode( name, + {}, + portInfos( configFields.value( name ), + true, + data.value( "input_types" ).toObject(), + data.value( "input_icons" ).toObject() ), + true ); m_scene->addItem( configNode ); - configNodes.insert( it.key(), configNode ); + m_configNodes.insert( name, configNode ); } } - auto slotHeight = [&nodes, &configNodes]( const QString& name ) + auto slotHeight = [this]( const QString& name ) { - qreal height = nodes.value( name )->height(); - if ( auto* config = configNodes.value( name, nullptr ) ) height = std::max( height, config->height() ); + qreal height = m_taskNodes.value( name )->height(); + if ( auto* config = m_configNodes.value( name, nullptr ) ) height = std::max( height, config->height() ); return height; }; - qreal totalHeight = 0; - for ( const QStringList& layer : layers ) + auto layerHeight = [&slotHeight]( const QStringList& layer ) { qreal height = 0; for ( const QString& name : layer ) height += slotHeight( name ) + rowGap; - totalHeight = std::max( totalHeight, height - rowGap ); - } - for ( auto it = layers.cbegin(); it != layers.cend(); ++it ) + return height - rowGap; + }; + + const auto layout = RiuWorkflowGraphLayout::computeLayers( taskNames, layoutEdges ); + qreal totalHeight = 0; + for ( const QStringList& layer : layout.layers ) + totalHeight = std::max( totalHeight, layerHeight( layer ) ); + for ( size_t column = 0; column < layout.layers.size(); ++column ) { - qreal layerHeight = 0; - for ( const QString& name : it.value() ) - layerHeight += slotHeight( name ) + rowGap; - qreal y = ( totalHeight - layerHeight + rowGap ) / 2.0; - for ( const QString& name : it.value() ) + const QStringList& layer = layout.layers[column]; + qreal y = ( totalHeight - layerHeight( layer ) ) / 2.0; + for ( const QString& name : layer ) { - auto* node = nodes.value( name ); - const qreal x = it.key() * columnStep + configWidth + configGap; + auto* node = m_taskNodes.value( name ); + const qreal x = column * columnStep + configWidth + configGap; node->setPos( x, y + ( slotHeight( name ) - node->height() ) / 2.0 ); - if ( auto* config = configNodes.value( name, nullptr ) ) + if ( auto* config = m_configNodes.value( name, nullptr ) ) { config->setPos( x - configWidth - configGap, y + ( slotHeight( name ) - config->height() ) / 2.0 ); } @@ -494,20 +1302,56 @@ void RiuWorkflowGraphView::showGraph( const QJsonObject& graph, const QString& e } } + // Positions the user (or a drop) has chosen override the layout + for ( auto it = m_taskNodes.cbegin(); it != m_taskNodes.cend(); ++it ) + { + const QString name = it.key(); + const bool pending = m_pendingPositions.contains( name ); + std::optional position; + if ( pending ) + position = m_pendingPositions.value( name ); + else if ( m_positions.contains( "task:" + name ) ) + position = m_positions.value( "task:" + name ); + if ( position ) it.value()->setPos( *position ); + + if ( GraphNode* config = m_configNodes.value( name, nullptr ) ) + { + if ( m_positions.contains( "config:" + name ) && !pending ) + config->setPos( m_positions.value( "config:" + name ) ); + else if ( position ) + config->setPos( *position + QPointF( -configWidth - configGap, 0 ) ); + } + } + m_pendingPositions.clear(); + + const bool selectableEdges = editMode; for ( const QJsonValue& value : edges ) { const QJsonObject edge = value.toObject(); const QString from = edge.value( "from" ).toString(); const QString to = edge.value( "to" ).toString(); - if ( !nodes.contains( from ) || !nodes.contains( to ) || from == to ) continue; - m_scene->addItem( new GraphEdge( nodes.value( from ), outputPortKey( edge ), nodes.value( to ), inputPortKey( edge ) ) ); + if ( !m_taskNodes.contains( from ) || !m_taskNodes.contains( to ) || from == to ) continue; + auto* graphEdge = new GraphEdge( m_taskNodes.value( from ), + keyForField( edge.value( "output" ).toString() ), + m_taskNodes.value( to ), + keyForField( edge.value( "input" ).toString() ), + false, + selectableEdges ); + if ( edge.contains( "collect" ) ) + { + const bool keyed = edge.value( "collect" ).toString() == "dict"; + graphEdge->setLabel( edge.value( "label" ).toString(), + keyed ? QString( "Collected under the key '%1'" ).arg( edge.value( "label" ).toString() ) + : QString( "Collected as item %1 of the list" ).arg( edge.value( "label" ).toString() ) ); + } + m_scene->addItem( graphEdge ); } - for ( auto it = configNodes.cbegin(); it != configNodes.cend(); ++it ) + for ( auto it = m_configNodes.cbegin(); it != m_configNodes.cend(); ++it ) { for ( const QString& key : configFields.value( it.key() ) ) { - m_scene->addItem( new GraphEdge( it.value(), key, nodes.value( it.key() ), key, true ) ); + m_scene->addItem( new GraphEdge( it.value(), key, m_taskNodes.value( it.key() ), key, true, false ) ); } } @@ -515,7 +1359,104 @@ void RiuWorkflowGraphView::showGraph( const QJsonObject& graph, const QString& e m_runStatusLabel->setDefaultTextColor( QColor( 45, 65, 80 ) ); m_runStatusLabel->setPos( 0, -45 ); m_scene->setSceneRect( m_scene->itemsBoundingRect().adjusted( -50, -50, 50, 50 ) ); - fitInView( m_scene->sceneRect(), Qt::KeepAspectRatio ); + + if ( keepViewport ) + { + setTransform( oldTransform ); + centerOn( oldCenter ); + } + else + { + fitInView( m_scene->sceneRect(), Qt::KeepAspectRatio ); + } + + m_lastSelectedTask.clear(); + if ( auto* node = m_taskNodes.value( selectedTask, nullptr ) ) + { + node->setSelected( true ); + m_lastSelectedTask = selectedTask; + } + m_rebuilding = false; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::setTaskIssues( const QString& taskName, const QJsonArray& issues ) +{ + if ( auto* node = m_taskNodes.value( taskName, nullptr ) ) node->setIssues( issues ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::selectTask( const QString& taskName ) +{ + auto* node = m_taskNodes.value( taskName, nullptr ); + if ( !node ) return; + + m_rebuilding = true; + m_scene->clearSelection(); + node->setSelected( true ); + m_lastSelectedTask = taskName; + m_rebuilding = false; + ensureVisible( node ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RiuWorkflowGraphView::ItemRef RiuWorkflowGraphView::ItemRef::forTask( const QString& taskName ) +{ + ItemRef item; + item.kind = Kind::Task; + item.task = taskName; + return item; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RiuWorkflowGraphView::ItemRef + RiuWorkflowGraphView::ItemRef::forEdge( const QString& from, const QString& output, const QString& to, const QString& input ) +{ + ItemRef item; + item.kind = Kind::Edge; + item.from = from; + item.output = output; + item.to = to; + item.input = input; + return item; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RiuWorkflowGraphView::ItemRef RiuWorkflowGraphView::selectedItem() const +{ + for ( QGraphicsItem* item : m_scene->selectedItems() ) + { + if ( auto* node = dynamic_cast( item ) ) return ItemRef::forTask( node->name() ); + if ( auto* edge = dynamic_cast( item ); edge && !edge->isConfig() ) + { + return ItemRef::forEdge( edge->fromNode()->name(), edge->outputField(), edge->toNode()->name(), edge->inputField() ); + } + } + return {}; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::onSelectionChanged() +{ + if ( m_rebuilding ) return; + + const ItemRef selected = selectedItem(); + if ( selected.kind != ItemRef::Kind::Task || selected.task == m_lastSelectedTask ) return; + + m_lastSelectedTask = selected.task; + emit nodeSelected( selected.task ); } //-------------------------------------------------------------------------------------------------- @@ -523,15 +1464,7 @@ void RiuWorkflowGraphView::showGraph( const QJsonObject& graph, const QString& e //-------------------------------------------------------------------------------------------------- void RiuWorkflowGraphView::setTaskInputValue( const QString& taskName, const QString& fieldName, const QString& value ) { - for ( QGraphicsItem* item : m_scene->items() ) - { - auto* node = dynamic_cast( item ); - if ( node && node->isConfig() && node->name() == taskName ) - { - node->setConfigValue( fieldName, value ); - return; - } - } + if ( auto* node = m_configNodes.value( taskName, nullptr ) ) node->setConfigValue( fieldName, value ); } //-------------------------------------------------------------------------------------------------- @@ -539,10 +1472,8 @@ void RiuWorkflowGraphView::setTaskInputValue( const QString& taskName, const QSt //-------------------------------------------------------------------------------------------------- void RiuWorkflowGraphView::resetTaskStates() { - for ( QGraphicsItem* item : m_scene->items() ) - { - if ( auto* node = dynamic_cast( item ) ) node->setTaskState( {} ); - } + for ( GraphNode* node : m_taskNodes ) + node->setTaskState( {} ); } //-------------------------------------------------------------------------------------------------- @@ -550,15 +1481,15 @@ void RiuWorkflowGraphView::resetTaskStates() //-------------------------------------------------------------------------------------------------- void RiuWorkflowGraphView::setTaskState( const QString& taskName, const QString& state, const QString& error ) { - for ( QGraphicsItem* item : m_scene->items() ) - { - auto* node = dynamic_cast( item ); - if ( node && !node->isConfig() && node->name() == taskName ) - { - node->setTaskState( state, error ); - return; - } - } + if ( auto* node = m_taskNodes.value( taskName, nullptr ) ) node->setTaskState( state, error ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::setTaskItemState( const QString& taskName, const QString& item, const QString& state, const QString& error ) +{ + if ( auto* node = m_taskNodes.value( taskName, nullptr ) ) node->setItemState( item, state, error ); } //-------------------------------------------------------------------------------------------------- @@ -571,6 +1502,156 @@ void RiuWorkflowGraphView::setRunStatus( const QString& status ) m_runStatusLabel->setToolTip( status ); } +//-------------------------------------------------------------------------------------------------- +/// The task port nearest to the cursor, within a few pixels +//-------------------------------------------------------------------------------------------------- +PortItem* RiuWorkflowGraphView::portAt( const QPoint& viewPos ) const +{ + const QPointF scenePos = mapToScene( viewPos ); + const qreal distance = portHitDist / std::max( 0.05, transform().m11() ); + PortItem* nearest = nullptr; + qreal best = distance; + for ( QGraphicsItem* item : m_scene->items( QRectF( scenePos - QPointF( distance, distance ), QSizeF( 2 * distance, 2 * distance ) ) ) ) + { + auto* port = dynamic_cast( item ); + if ( !port || port->isConfig() ) continue; + const QPointF delta = port->mapToScene( port->rect().center() ) - scenePos; + const qreal d = std::hypot( delta.x(), delta.y() ); + if ( d <= best ) + { + best = d; + nearest = port; + } + } + return nearest; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RiuWorkflowGraphView::ItemRef RiuWorkflowGraphView::itemRefAt( const QPoint& viewPos ) const +{ + for ( QGraphicsItem* item : items( viewPos ) ) + { + QGraphicsItem* current = item; + while ( current ) + { + if ( auto* node = dynamic_cast( current ) ) return ItemRef::forTask( node->name() ); + if ( auto* edge = dynamic_cast( current ) ) + { + if ( edge->isConfig() ) break; + return ItemRef::forEdge( edge->fromNode()->name(), edge->outputField(), edge->toNode()->name(), edge->inputField() ); + } + current = current->parentItem(); + } + } + return {}; +} + +//-------------------------------------------------------------------------------------------------- +/// Highlight the ports the dragged port can connect to +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::startConnectionDrag( PortItem* port ) +{ + m_dragPort = port; + m_dragInvalidReasons.clear(); + + for ( GraphNode* node : m_taskNodes ) + { + for ( PortItem* candidate : node->ports() ) + { + if ( candidate->isOutput() == port->isOutput() ) continue; + + PortItem* outputPort = port->isOutput() ? port : candidate; + PortItem* inputPort = port->isOutput() ? candidate : port; + + std::expected valid; + if ( outputPort->node() == inputPort->node() ) + valid = std::unexpected( QString( "A task cannot be connected to itself" ) ); + else if ( m_validator ) + valid = m_validator( outputPort->node()->name(), outputPort->field(), inputPort->node()->name(), inputPort->field() ); + + if ( valid ) + candidate->setHighlight( PortItem::Highlight::Valid ); + else + { + candidate->setHighlight( PortItem::Highlight::Invalid, valid.error() ); + m_dragInvalidReasons.insert( candidate, valid.error() ); + } + } + } + + m_dragPath = m_scene->addPath( QPainterPath(), QPen( validTargetColor.darker( 120 ), 2.0, Qt::DashLine ) ); + m_dragPath->setZValue( 3 ); + updateConnectionDrag( port->mapToScene( port->rect().center() ) ); + setCursor( Qt::CrossCursor ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::updateConnectionDrag( const QPointF& scenePos ) +{ + if ( !m_dragPort || !m_dragPath ) return; + + m_dragTarget = nullptr; + QPointF end = scenePos; + if ( PortItem* target = portAt( mapFromScene( scenePos ) ); + target && target->isOutput() != m_dragPort->isOutput() && target->node() != m_dragPort->node() ) + { + if ( !m_dragInvalidReasons.contains( target ) ) + { + m_dragTarget = target; + end = target->mapToScene( target->rect().center() ); + } + else + { + QToolTip::showText( mapToGlobal( mapFromScene( scenePos ) ), m_dragInvalidReasons.value( target ), this ); + } + } + + const QPointF start = m_dragPort->mapToScene( m_dragPort->rect().center() ); + QPointF from = m_dragPort->isOutput() ? start : end; + QPointF to = m_dragPort->isOutput() ? end : start; + const qreal bend = std::max( 35.0, std::abs( to.x() - from.x() ) / 2.0 ); + QPainterPath path( from ); + path.cubicTo( from + QPointF( bend, 0 ), to - QPointF( bend, 0 ), to ); + m_dragPath->setPath( path ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::finishConnectionDrag( bool connect ) +{ + if ( !m_dragPort ) return; + + PortItem* source = m_dragPort; + PortItem* target = m_dragTarget; + m_dragPort = nullptr; + m_dragTarget = nullptr; + m_dragInvalidReasons.clear(); + if ( m_dragPath ) + { + m_scene->removeItem( m_dragPath ); + delete m_dragPath; + m_dragPath = nullptr; + } + for ( GraphNode* node : m_taskNodes ) + { + for ( PortItem* port : node->ports() ) + port->setHighlight( PortItem::Highlight::None ); + } + unsetCursor(); + QToolTip::hideText(); + + if ( !connect || !target ) return; + + PortItem* outputPort = source->isOutput() ? source : target; + PortItem* inputPort = source->isOutput() ? target : source; + emit connectRequested( outputPort->node()->name(), outputPort->field(), inputPort->node()->name(), inputPort->field() ); +} + //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- @@ -586,9 +1667,48 @@ void RiuWorkflowGraphView::resizeEvent( QResizeEvent* event ) void RiuWorkflowGraphView::mousePressEvent( QMouseEvent* event ) { if ( event->button() == Qt::LeftButton ) m_fitOnResize = false; + + if ( m_editable && event->button() == Qt::LeftButton ) + { + if ( PortItem* port = portAt( event->pos() ) ) + { + startConnectionDrag( port ); + event->accept(); + return; + } + } QGraphicsView::mousePressEvent( event ); } +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::mouseMoveEvent( QMouseEvent* event ) +{ + if ( m_dragPort ) + { + updateConnectionDrag( mapToScene( event->pos() ) ); + event->accept(); + return; + } + QGraphicsView::mouseMoveEvent( event ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::mouseReleaseEvent( QMouseEvent* event ) +{ + if ( m_dragPort ) + { + updateConnectionDrag( mapToScene( event->pos() ) ); + finishConnectionDrag( true ); + event->accept(); + return; + } + QGraphicsView::mouseReleaseEvent( event ); +} + //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- @@ -604,3 +1724,110 @@ void RiuWorkflowGraphView::wheelEvent( QWheelEvent* event ) } QGraphicsView::wheelEvent( event ); } + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::keyPressEvent( QKeyEvent* event ) +{ + if ( event->key() == Qt::Key_Escape && m_dragPort ) + { + finishConnectionDrag( false ); + event->accept(); + return; + } + + if ( m_editable ) + { + const ItemRef selected = selectedItem(); + + // A config box carries the name of its task; deleting it must not delete the task + const auto selectedItems = m_scene->selectedItems(); + const bool configSelected = std::any_of( selectedItems.begin(), + selectedItems.end(), + []( QGraphicsItem* item ) + { + auto* node = dynamic_cast( item ); + return node && node->isConfig(); + } ); + if ( ( event->key() == Qt::Key_Delete || event->key() == Qt::Key_Backspace ) && selected.kind != ItemRef::Kind::None && !configSelected ) + { + emit deleteRequested( selected ); + event->accept(); + return; + } + if ( event->key() == Qt::Key_F2 && selected.kind == ItemRef::Kind::Task ) + { + emit renameRequested( selected.task ); + event->accept(); + return; + } + } + QGraphicsView::keyPressEvent( event ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::contextMenuEvent( QContextMenuEvent* event ) +{ + const ItemRef item = itemRefAt( event->pos() ); + + m_rebuilding = true; + m_scene->clearSelection(); + for ( QGraphicsItem* graphicsItem : items( event->pos() ) ) + { + QGraphicsItem* current = graphicsItem; + while ( current && !( current->flags() & QGraphicsItem::ItemIsSelectable ) ) + current = current->parentItem(); + if ( current ) + { + current->setSelected( true ); + break; + } + } + m_rebuilding = false; + onSelectionChanged(); + + emit contextMenuRequested( item, event->globalPos(), mapToScene( event->pos() ) ); + event->accept(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::dragEnterEvent( QDragEnterEvent* event ) +{ + if ( m_editable && event->mimeData()->hasFormat( taskMimeType ) ) + event->acceptProposedAction(); + else + event->ignore(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::dragMoveEvent( QDragMoveEvent* event ) +{ + if ( m_editable && event->mimeData()->hasFormat( taskMimeType ) ) + event->acceptProposedAction(); + else + event->ignore(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowGraphView::dropEvent( QDropEvent* event ) +{ + if ( !m_editable || !event->mimeData()->hasFormat( taskMimeType ) ) + { + event->ignore(); + return; + } + + const QString taskId = QString::fromUtf8( event->mimeData()->data( taskMimeType ) ); + event->acceptProposedAction(); + m_fitOnResize = false; + emit addTaskRequested( taskId, mapToScene( event->position().toPoint() ) ); +} diff --git a/ApplicationLibCode/UserInterface/RiuWorkflowGraphView.h b/ApplicationLibCode/UserInterface/RiuWorkflowGraphView.h index 99236e10bc..dcaabdd44f 100644 --- a/ApplicationLibCode/UserInterface/RiuWorkflowGraphView.h +++ b/ApplicationLibCode/UserInterface/RiuWorkflowGraphView.h @@ -14,35 +14,138 @@ // ///////////////////////////////////////////////////////////////////////////////// +#pragma once #pragma once #include +#include #include +#include +#include + +#include +#include class QGraphicsScene; +class QGraphicsPathItem; class QGraphicsTextItem; -class QMouseEvent; -class QResizeEvent; -class QWheelEvent; +namespace RiuWorkflowGraphViewItems +{ +class GraphNode; +class PortItem; +} // namespace RiuWorkflowGraphViewItems + +//================================================================================================== +/// Node graph of a workflow. Shows run states of a job and, in edit mode, lets the user connect +/// ports by dragging, select and delete tasks and connections, and drop tasks from the palette. +/// The view never changes the workflow itself; it emits requests handled by the editor widget. +//================================================================================================== class RiuWorkflowGraphView : public QGraphicsView { -public: - explicit RiuWorkflowGraphView( QWidget* parent = nullptr ); + Q_OBJECT + +public: + enum class ViewportPolicy + { + Fit, + Keep + }; + + struct ItemRef + { + enum class Kind + { + None, + Task, + Edge + }; + + Kind kind = Kind::None; + QString task; + QString from; + QString output; + QString to; + QString input; + + static ItemRef forTask( const QString& taskName ); + static ItemRef forEdge( const QString& from, const QString& output, const QString& to, const QString& input ); + }; + + // Returns why a connection is not possible. Fields are empty for the whole input or output. + using ConnectionValidator = + std::function( const QString& from, const QString& output, const QString& to, const QString& input )>; + + static constexpr const char* taskMimeType = "application/x-resinsight-workflow-task"; + + explicit RiuWorkflowGraphView( QWidget* parent = nullptr ); + ~RiuWorkflowGraphView() override; + + void showGraph( const QJsonObject& graph, const QString& error, ViewportPolicy policy = ViewportPolicy::Fit ); + void fitGraph(); + + void setEditable( bool editable ); + bool isEditable() const; + void setConnectionValidator( const ConnectionValidator& validator ); + void setTaskIssues( const QString& taskName, const QJsonArray& issues ); + + void setPendingNodePosition( const QString& taskName, const QPointF& scenePos ); + void renameNodePosition( const QString& oldName, const QString& newName ); + void selectTask( const QString& taskName ); + ItemRef selectedItem() const; - void showGraph( const QJsonObject& graph, const QString& error ); void setTaskInputValue( const QString& taskName, const QString& fieldName, const QString& value ); void resetTaskStates(); void setTaskState( const QString& taskName, const QString& state, const QString& error = {} ); + void setTaskItemState( const QString& taskName, const QString& item, const QString& state, const QString& error = {} ); void setRunStatus( const QString& status ); +signals: + void nodeSelected( const QString& taskName ); + void connectRequested( const QString& from, const QString& outputField, const QString& to, const QString& inputField ); + void deleteRequested( const RiuWorkflowGraphView::ItemRef& item ); + void renameRequested( const QString& taskName ); + void addTaskRequested( const QString& taskId, const QPointF& scenePos ); + void contextMenuRequested( const RiuWorkflowGraphView::ItemRef& item, const QPoint& globalPos, const QPointF& scenePos ); + protected: void resizeEvent( QResizeEvent* event ) override; void mousePressEvent( QMouseEvent* event ) override; + void mouseMoveEvent( QMouseEvent* event ) override; + void mouseReleaseEvent( QMouseEvent* event ) override; void wheelEvent( QWheelEvent* event ) override; + void keyPressEvent( QKeyEvent* event ) override; + void contextMenuEvent( QContextMenuEvent* event ) override; + void dragEnterEvent( QDragEnterEvent* event ) override; + void dragMoveEvent( QDragMoveEvent* event ) override; + void dropEvent( QDropEvent* event ) override; private: - QGraphicsScene* m_scene; - QGraphicsTextItem* m_runStatusLabel = nullptr; - bool m_fitOnResize = true; + void onSelectionChanged(); + void rememberPositions(); + + RiuWorkflowGraphViewItems::PortItem* portAt( const QPoint& viewPos ) const; + ItemRef itemRefAt( const QPoint& viewPos ) const; + void startConnectionDrag( RiuWorkflowGraphViewItems::PortItem* port ); + void updateConnectionDrag( const QPointF& scenePos ); + void finishConnectionDrag( bool connect ); + +private: + QGraphicsScene* m_scene; + QGraphicsTextItem* m_runStatusLabel = nullptr; + bool m_fitOnResize = true; + bool m_editable = false; + bool m_rebuilding = false; + ConnectionValidator m_validator; + QString m_lastSelectedTask; + + QMap m_taskNodes; + QMap m_configNodes; + QMap m_positions; + QMap m_pendingPositions; + + RiuWorkflowGraphViewItems::PortItem* m_dragPort = nullptr; + RiuWorkflowGraphViewItems::PortItem* m_dragTarget = nullptr; + QGraphicsPathItem* m_dragPath = nullptr; + QMap m_dragInvalidReasons; }; diff --git a/ApplicationLibCode/UserInterface/RiuWorkflowJobRunner.cpp b/ApplicationLibCode/UserInterface/RiuWorkflowJobRunner.cpp index 15fddbf32d..3e078f0225 100644 --- a/ApplicationLibCode/UserInterface/RiuWorkflowJobRunner.cpp +++ b/ApplicationLibCode/UserInterface/RiuWorkflowJobRunner.cpp @@ -169,6 +169,16 @@ void RiuWorkflowJobRunner::drainLines( QByteArray& buffer, RILogLevel level ) event.value( "error" ).toString() ); continue; } + if ( event.value( "event" ) == "map_item_state" && event.value( "run_id" ).isString() && event.value( "task" ).isString() && + event.value( "item" ).isString() && event.value( "state" ).isString() ) + { + emit mapItemStateChanged( event.value( "run_id" ).toString(), + event.value( "task" ).toString(), + event.value( "item" ).toString(), + event.value( "state" ).toString(), + event.value( "error" ).toString() ); + continue; + } } } diff --git a/ApplicationLibCode/UserInterface/RiuWorkflowJobRunner.h b/ApplicationLibCode/UserInterface/RiuWorkflowJobRunner.h index ff525f706e..21fe7e7901 100644 --- a/ApplicationLibCode/UserInterface/RiuWorkflowJobRunner.h +++ b/ApplicationLibCode/UserInterface/RiuWorkflowJobRunner.h @@ -38,6 +38,7 @@ public: signals: void taskStateChanged( const QString& runId, const QString& taskName, const QString& state, const QString& error ); + void mapItemStateChanged( const QString& runId, const QString& taskName, const QString& item, const QString& state, const QString& error ); void runFinished( bool succeeded, bool cancelled ); private slots: diff --git a/ApplicationLibCode/UserInterface/RiuWorkflowTaskPalette.cpp b/ApplicationLibCode/UserInterface/RiuWorkflowTaskPalette.cpp new file mode 100644 index 0000000000..16b79e2b4e --- /dev/null +++ b/ApplicationLibCode/UserInterface/RiuWorkflowTaskPalette.cpp @@ -0,0 +1,194 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// at for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#include "RiuWorkflowTaskPalette.h" + +#include "RiuWorkflowGraphView.h" + +#include "Workflow/RimWorkflowPortCompatibility.h" +#include "Workflow/RimWorkflowTaskCatalog.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr int taskIdRole = Qt::UserRole + 1; +constexpr int searchTextRole = Qt::UserRole + 2; + +//================================================================================================== +/// Puts the task id under the workflow task mime type when a task is dragged +//================================================================================================== +class TaskModel : public QStandardItemModel +{ +public: + using QStandardItemModel::QStandardItemModel; + + QStringList mimeTypes() const override { return { RiuWorkflowGraphView::taskMimeType }; } + + QMimeData* mimeData( const QModelIndexList& indexes ) const override + { + for ( const QModelIndex& index : indexes ) + { + const QString taskId = index.data( taskIdRole ).toString(); + if ( taskId.isEmpty() ) continue; + + auto* data = new QMimeData; + data->setData( RiuWorkflowGraphView::taskMimeType, taskId.toUtf8() ); + data->setText( taskId ); + return data; + } + return nullptr; + } +}; + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QString portSummary( const std::vector& ports ) +{ + QStringList lines; + for ( const auto& port : ports ) + { + QString line = QString( " %1: %2" ).arg( port.isWhole() ? "(whole)" : port.name, port.typeName ); + if ( !port.required ) line += " (optional)"; + lines << line; + } + return lines.isEmpty() ? " (none)" : lines.join( "\n" ); +} + +//-------------------------------------------------------------------------------------------------- +/// "resinsight.load_model" is in the "resinsight" group +//-------------------------------------------------------------------------------------------------- +QString groupName( const QString& taskId ) +{ + const int dot = taskId.lastIndexOf( '.' ); + return dot > 0 ? taskId.left( dot ) : QString( "Other" ); +} +} // namespace + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RiuWorkflowTaskPalette::RiuWorkflowTaskPalette( QWidget* parent ) + : QWidget( parent ) + , m_filter( new QLineEdit( this ) ) + , m_treeView( new QTreeView( this ) ) + , m_model( new TaskModel( this ) ) + , m_proxyModel( new QSortFilterProxyModel( this ) ) +{ + m_filter->setPlaceholderText( "Filter tasks" ); + m_filter->setClearButtonEnabled( true ); + + m_proxyModel->setSourceModel( m_model ); + m_proxyModel->setFilterRole( searchTextRole ); + m_proxyModel->setFilterCaseSensitivity( Qt::CaseInsensitive ); + m_proxyModel->setRecursiveFilteringEnabled( true ); + + m_treeView->setModel( m_proxyModel ); + m_treeView->setHeaderHidden( true ); + m_treeView->setDragEnabled( true ); + m_treeView->setDragDropMode( QAbstractItemView::DragOnly ); + m_treeView->setEditTriggers( QAbstractItemView::NoEditTriggers ); + m_treeView->setSelectionMode( QAbstractItemView::SingleSelection ); + + auto* layout = new QVBoxLayout( this ); + layout->setContentsMargins( 0, 0, 0, 0 ); + layout->addWidget( new QLabel( "Tasks", this ) ); + layout->addWidget( m_filter ); + layout->addWidget( m_treeView ); + + connect( m_filter, + &QLineEdit::textChanged, + this, + [this]( const QString& text ) + { + m_proxyModel->setFilterFixedString( text ); + if ( !text.isEmpty() ) m_treeView->expandAll(); + } ); + connect( m_treeView, &QTreeView::doubleClicked, this, &RiuWorkflowTaskPalette::onDoubleClicked ); + + reload(); +} + +//-------------------------------------------------------------------------------------------------- +/// Fill the tree from the task catalog +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowTaskPalette::reload() +{ + m_model->clear(); + + const RimWorkflowTaskCatalog& catalog = RimWorkflowTaskCatalog::instance(); + if ( !catalog.isValid() ) + { + auto* item = new QStandardItem( "Task catalog not available" ); + item->setToolTip( catalog.loadError() ); + item->setEnabled( false ); + item->setDragEnabled( false ); + m_model->appendRow( item ); + return; + } + + QMap groups; + for ( const QJsonObject& task : catalog.tasks() ) + { + const QString taskId = task.value( "id" ).toString(); + if ( taskId.isEmpty() ) continue; + + const QString group = groupName( taskId ); + if ( !groups.contains( group ) ) + { + auto* groupItem = new QStandardItem( group ); + groupItem->setData( group, searchTextRole ); + groupItem->setDragEnabled( false ); + groupItem->setSelectable( false ); + m_model->appendRow( groupItem ); + groups.insert( group, groupItem ); + } + + const QString name = task.value( "name" ).toString( taskId.mid( taskId.lastIndexOf( '.' ) + 1 ) ); + const QString description = task.value( "description" ).toString(); + + auto* item = new QStandardItem( name ); + item->setData( taskId, taskIdRole ); + item->setData( QStringList{ taskId, name, description }.join( "\n" ), searchTextRole ); + item->setToolTip( QString( "%1 (%2)

%3

Inputs:\n%4\nOutputs:\n%5
" ) + .arg( name.toHtmlEscaped(), + taskId.toHtmlEscaped(), + description.toHtmlEscaped(), + portSummary( RimWorkflowPortCompatibility::inputPorts( task ) ).toHtmlEscaped(), + portSummary( RimWorkflowPortCompatibility::outputPorts( task ) ).toHtmlEscaped() ) ); + item->setDragEnabled( true ); + groups.value( group )->appendRow( item ); + } + + m_treeView->expandAll(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowTaskPalette::onDoubleClicked( const QModelIndex& index ) +{ + const QString taskId = index.data( taskIdRole ).toString(); + if ( !taskId.isEmpty() ) emit taskActivated( taskId ); +} diff --git a/ApplicationLibCode/UserInterface/RiuWorkflowTaskPalette.h b/ApplicationLibCode/UserInterface/RiuWorkflowTaskPalette.h new file mode 100644 index 0000000000..ec89e64652 --- /dev/null +++ b/ApplicationLibCode/UserInterface/RiuWorkflowTaskPalette.h @@ -0,0 +1,51 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// at for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include + +class QLineEdit; +class QStandardItemModel; +class QSortFilterProxyModel; +class QTreeView; +class QModelIndex; + +//================================================================================================== +/// The task types published by installed packages, grouped by package. Tasks are added to the +/// workflow by dragging them onto the graph or by double-clicking. +//================================================================================================== +class RiuWorkflowTaskPalette : public QWidget +{ + Q_OBJECT + +public: + explicit RiuWorkflowTaskPalette( QWidget* parent = nullptr ); + + void reload(); + +signals: + void taskActivated( const QString& taskId ); + +private: + void onDoubleClicked( const QModelIndex& index ); + +private: + QLineEdit* m_filter; + QTreeView* m_treeView; + QStandardItemModel* m_model; + QSortFilterProxyModel* m_proxyModel; +}; diff --git a/ApplicationLibCode/UserInterface/RiuWorkflowValidationRunner.cpp b/ApplicationLibCode/UserInterface/RiuWorkflowValidationRunner.cpp new file mode 100644 index 0000000000..5d979590e9 --- /dev/null +++ b/ApplicationLibCode/UserInterface/RiuWorkflowValidationRunner.cpp @@ -0,0 +1,225 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// at for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#include "RiuWorkflowValidationRunner.h" + +#include "Workflow/RimWorkflow.h" +#include "Workflow/RimWorkflowHelperProcess.h" +#include "Workflow/RimWorkflowValidationTools.h" + +#include "RiaLogging.h" + +#include +#include + +namespace +{ +RimWorkflowIssue issueWithMessage( const QString& message ) +{ + RimWorkflowIssue issue; + issue.message = message; + return issue; +} +} // namespace + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RiuWorkflowValidationRunner::RiuWorkflowValidationRunner( QObject* parent ) + : QObject( parent ) + , m_debounceTimer( new QTimer( this ) ) + , m_watchdogTimer( new QTimer( this ) ) +{ + m_debounceTimer->setSingleShot( true ); + m_debounceTimer->setInterval( debounceMs ); + connect( m_debounceTimer, &QTimer::timeout, this, &RiuWorkflowValidationRunner::start ); + + m_watchdogTimer->setSingleShot( true ); + m_watchdogTimer->setInterval( watchdogMs ); + connect( m_watchdogTimer, &QTimer::timeout, this, &RiuWorkflowValidationRunner::onTimeout ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RiuWorkflowValidationRunner::~RiuWorkflowValidationRunner() +{ + stopProcess(); +} + +//-------------------------------------------------------------------------------------------------- +/// Validate the current revision of the workflow after a short pause. A run in progress for an +/// older revision is stopped. +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowValidationRunner::schedule( RimWorkflow* workflow ) +{ + if ( !workflow || !workflow->isEditable() || workflow->isValidationCurrent() ) + { + if ( workflow == m_workflow.p() || !workflow ) m_debounceTimer->stop(); + return; + } + + m_workflow = workflow; + if ( m_process && ( m_runningWorkflow.p() != workflow || m_runningRevision != workflow->revision() ) ) stopProcess(); + m_debounceTimer->start(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowValidationRunner::cancel() +{ + m_debounceTimer->stop(); + stopProcess(); + m_workflow = nullptr; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RiuWorkflowValidationRunner::isRunning() const +{ + return m_process != nullptr || m_debounceTimer->isActive(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowValidationRunner::start() +{ + RimWorkflow* workflow = m_workflow.p(); + if ( !workflow || !workflow->isEditable() || workflow->isValidationCurrent() ) return; + if ( m_process && m_runningWorkflow.p() == workflow && m_runningRevision == workflow->revision() ) return; + stopProcess(); + + const QString python = RimWorkflow::findPythonExecutable(); + auto editDirectory = workflow->editDirectory(); + if ( python.isEmpty() || !editDirectory ) + { + const QString message = python.isEmpty() ? QString( "No usable Python interpreter found" ) : editDirectory.error(); + workflow->setValidationResult( workflow->revision(), { issueWithMessage( message ) }, false ); + emit validationFinished( workflow ); + return; + } + + m_runningWorkflow = workflow; + m_runningRevision = workflow->revision(); + + auto* process = new QProcess( this ); + m_process = process; + process->setProcessEnvironment( RimWorkflowHelperProcess::environment( workflow->sourceDirectory() ) ); + connect( process, &QProcess::finished, this, &RiuWorkflowValidationRunner::onFinished ); + connect( process, + &QProcess::errorOccurred, + this, + [this]( QProcess::ProcessError error ) + { + if ( error == QProcess::FailedToStart ) onFinished(); + } ); + + process->start( python, RimWorkflowHelperProcess::helperArguments( { "save", "--out-dir", editDirectory.value(), "--describe" } ) ); + + // FailedToStart can be reported from within start(), which already finished the validation + if ( m_process != process ) return; + + process->write( workflow->definitionJsonForSave() ); + process->closeWriteChannel(); + m_watchdogTimer->start(); + emit validationStarted(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowValidationRunner::onFinished() +{ + if ( !m_process ) return; + m_watchdogTimer->stop(); + + QProcess* process = m_process; + const int exitCode = process->exitStatus() == QProcess::NormalExit && process->error() != QProcess::FailedToStart ? process->exitCode() + : -1; + const QByteArray standardOutput = process->readAllStandardOutput(); + const QByteArray standardError = process->readAllStandardError(); + + // An invalid definition is reported with the task and field it refers to + std::expected output; + if ( const auto invalid = RimWorkflowHelperProcess::invalidResult( standardOutput ) ) + output = invalid.value(); + else + output = RimWorkflowHelperProcess::parseOutput( exitCode, standardOutput, standardError ); + m_process = nullptr; + process->deleteLater(); + + RimWorkflow* workflow = m_runningWorkflow.p(); + const int revision = m_runningRevision; + m_runningWorkflow = nullptr; + m_runningRevision = -1; + if ( !workflow || workflow->revision() != revision ) return; + + if ( output ) + { + const auto validation = RimWorkflowValidationTools::issuesFromHelperResult( output.value(), workflow->definition().nodeNames() ); + workflow->setValidationResult( revision, validation.issues, validation.describeSucceeded ); + } + else + { + RiaLogging::debug( QString( "Workflow validation failed: %1" ).arg( output.error() ).toStdString() ); + workflow->setValidationResult( revision, { issueWithMessage( output.error() ) }, false ); + } + emit validationFinished( workflow ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowValidationRunner::onTimeout() +{ + RimWorkflow* workflow = m_runningWorkflow.p(); + const int revision = m_runningRevision; + stopProcess(); + if ( workflow && workflow->revision() == revision ) + { + workflow->setValidationResult( revision, { issueWithMessage( "Validation timed out" ) }, false ); + emit validationFinished( workflow ); + } +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiuWorkflowValidationRunner::stopProcess() +{ + m_watchdogTimer->stop(); + if ( m_process ) + { + QProcess* process = m_process; + m_process = nullptr; + process->disconnect( this ); + if ( process->state() == QProcess::NotRunning ) + { + process->deleteLater(); + } + else + { + // Delete when the killed process has exited, without blocking the GUI thread + connect( process, &QProcess::finished, process, &QObject::deleteLater ); + process->kill(); + } + } + m_runningWorkflow = nullptr; + m_runningRevision = -1; +} diff --git a/ApplicationLibCode/UserInterface/RiuWorkflowValidationRunner.h b/ApplicationLibCode/UserInterface/RiuWorkflowValidationRunner.h new file mode 100644 index 0000000000..48949730c3 --- /dev/null +++ b/ApplicationLibCode/UserInterface/RiuWorkflowValidationRunner.h @@ -0,0 +1,65 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// at for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "cafPdmPointer.h" + +#include +#include + +class QProcess; +class QTimer; +class RimWorkflow; + +//================================================================================================== +/// Checks the edited workflow with `taskmaestro workflow describe` in the background: a short +/// pause after the last edit, then `taskmaestro_helper save --describe` into the workflow's edit +/// folder. Results for an older revision are dropped. +//================================================================================================== +class RiuWorkflowValidationRunner : public QObject +{ + Q_OBJECT + +public: + static constexpr int debounceMs = 400; + static constexpr int watchdogMs = 30000; + + explicit RiuWorkflowValidationRunner( QObject* parent = nullptr ); + ~RiuWorkflowValidationRunner() override; + + void schedule( RimWorkflow* workflow ); + void cancel(); + bool isRunning() const; + +signals: + void validationStarted(); + void validationFinished( RimWorkflow* workflow ); + +private: + void start(); + void onFinished(); + void onTimeout(); + void stopProcess(); + +private: + QTimer* m_debounceTimer; + QTimer* m_watchdogTimer; + QPointer m_process; + caf::PdmPointer m_workflow; + caf::PdmPointer m_runningWorkflow; + int m_runningRevision = -1; +}; diff --git a/GrpcInterface/Python/pyproject.toml b/GrpcInterface/Python/pyproject.toml index e058608f24..9ee6d12dbe 100644 --- a/GrpcInterface/Python/pyproject.toml +++ b/GrpcInterface/Python/pyproject.toml @@ -41,6 +41,8 @@ dev = [ "types-protobuf", "packaging>=22.0", ] +# Needed by rips.taskmaestro_helper (the ResInsight workflow editor) +workflow = ["taskmaestro>=0.4,<0.5"] [project.urls] Homepage = "http://www.resinsight.org" diff --git a/GrpcInterface/Python/rips/taskmaestro_helper/__init__.py b/GrpcInterface/Python/rips/taskmaestro_helper/__init__.py index cc4a3947c6..d961e18f0f 100644 --- a/GrpcInterface/Python/rips/taskmaestro_helper/__init__.py +++ b/GrpcInterface/Python/rips/taskmaestro_helper/__init__.py @@ -1,6 +1,10 @@ """Bridge between ResInsight and the taskmaestro workflow library. -ResInsight invokes this package as a subprocess to execute workflows against -a running ResInsight. Workflow schemas are read with the taskmaestro CLI -(`python -m taskmaestro workflow describe`). +ResInsight invokes this package as a subprocess (``python -m +rips.taskmaestro_helper ``) to: + +- list installed tasks and workflows (``catalog``), +- convert workflow folders and registered workflows to editable workflow + definitions (``load``, ``export-registered``) and back to YAML (``save``), +- execute workflows against a running ResInsight (``run``). """ diff --git a/GrpcInterface/Python/rips/taskmaestro_helper/__main__.py b/GrpcInterface/Python/rips/taskmaestro_helper/__main__.py index 5396a160bd..c63161f6e8 100644 --- a/GrpcInterface/Python/rips/taskmaestro_helper/__main__.py +++ b/GrpcInterface/Python/rips/taskmaestro_helper/__main__.py @@ -1,28 +1,134 @@ -"""Command-line entry point: `python -m rips.taskmaestro_helper `.""" +"""Command-line entry point: `python -m rips.taskmaestro_helper `. + +Subcommands other than ``run`` print exactly one JSON document to stdout. Any +output written while importing task modules is redirected to stderr, including +output written by extension modules and child processes straight to file +descriptor 1. On +failure they exit with code 2 and print +``{"status": "invalid", "error": {"message", "task", "field"}}``. +""" from __future__ import annotations +import json +import os import sys +from collections.abc import Callable, Iterator +from contextlib import contextmanager, redirect_stdout +from typing import Any + +USAGE = ( + "usage: python -m rips.taskmaestro_helper " + "{catalog,load,save,export-registered,run} ..." +) -def main() -> int: - if len(sys.argv) < 2: +def _catalog(argv: list[str]) -> dict[str, Any]: + from .catalog import catalog_main + + return catalog_main(argv) + + +def _load(argv: list[str]) -> dict[str, Any]: + from .definition import load_main + + return load_main(argv) + + +def _save(argv: list[str]) -> dict[str, Any]: + from .definition import save_main + + return save_main(argv, sys.stdin.read()) + + +def _export_registered(argv: list[str]) -> dict[str, Any]: + from .catalog import export_registered_main + + return export_registered_main(argv) + + +JSON_COMMANDS: dict[str, Callable[[list[str]], dict[str, Any]]] = { + "catalog": _catalog, + "load": _load, + "save": _save, + "export-registered": _export_registered, +} + + +def _invalid(error: dict[str, Any]) -> str: + return json.dumps({"status": "invalid", "error": error}) + + +def _error_json(exc: Exception) -> dict[str, Any]: + to_json = getattr(exc, "to_json", None) # taskmaestro's DefinitionError + if callable(to_json): + return dict(to_json()) + if isinstance(exc, ImportError) and (exc.name or "").startswith("taskmaestro"): + message = f"taskmaestro>=0.4,<0.5 is required to edit workflows: {exc}" + else: + message = f"{type(exc).__name__}: {exc}" + return {"message": message, "task": None, "field": None} + + +@contextmanager +def _stdout_to_stderr() -> Iterator[None]: + """Send everything written to stdout to stderr, at the file descriptor level.""" + sys.stdout.flush() + try: + saved_fd = os.dup(1) + except OSError: # no usable stdout file descriptor; redirect Python output only + saved_fd = None + try: + if saved_fd is not None: + os.dup2(2, 1) + with redirect_stdout(sys.stderr): + yield + finally: + sys.stderr.flush() + if saved_fd is not None: + os.dup2(saved_fd, 1) + os.close(saved_fd) + + +def run_json_command( + command: Callable[[list[str]], dict[str, Any]], argv: list[str] +) -> int: + stdout = sys.stdout + try: + with _stdout_to_stderr(): + result = command(argv) + except SystemExit as exc: # argparse errors print( - "usage: python -m rips.taskmaestro_helper run ...", - file=sys.stderr, + _invalid( + {"message": f"Invalid arguments: {exc}", "task": None, "field": None} + ), + file=stdout, ) return 2 + except Exception as exc: # noqa: BLE001 - always answer ResInsight with JSON + print(_invalid(_error_json(exc)), file=stdout) + return 2 + print(json.dumps(result), file=stdout) + return 0 - subcommand = sys.argv[1] - rest = sys.argv[2:] +def main(argv: list[str] | None = None) -> int: + args = sys.argv[1:] if argv is None else argv + if not args: + print(USAGE, file=sys.stderr) + return 2 + + subcommand, rest = args[0], args[1:] if subcommand == "run": from . import run return run.main(rest) - print(f"unknown subcommand: {subcommand}", file=sys.stderr) - return 2 + command = JSON_COMMANDS.get(subcommand) + if command is None: + print(f"unknown subcommand: {subcommand}\n{USAGE}", file=sys.stderr) + return 2 + return run_json_command(command, rest) if __name__ == "__main__": diff --git a/GrpcInterface/Python/rips/taskmaestro_helper/catalog.py b/GrpcInterface/Python/rips/taskmaestro_helper/catalog.py new file mode 100644 index 0000000000..f5accaf28e --- /dev/null +++ b/GrpcInterface/Python/rips/taskmaestro_helper/catalog.py @@ -0,0 +1,132 @@ +"""Catalog of installed taskmaestro tasks and workflows. + +Every entry point is loaded on its own, so one broken plugin is reported in +``errors`` while the rest of the catalog still loads. +""" + +from __future__ import annotations + +from typing import Any + +import taskmaestro +from taskmaestro.exceptions import DefinitionError + +from .definition import definition_from_workflow +from .schema import task_descriptor + +CATALOG_FORMAT = 1 + + +def _error(problem: Any) -> dict[str, Any]: + return { + "kind": problem.kind, + "id": problem.name, + "entry_point": problem.entry_point, + "message": problem.message, + } + + +def class_ids(tasks: dict[str, type[Any]]) -> dict[type[Any], str]: + """Map task classes back to their (first) entry-point id.""" + result: dict[type[Any], str] = {} + for task_id, cls in sorted(tasks.items()): + result.setdefault(cls, task_id) + return result + + +def _workflow_entry( + workflow_id: str, workflow: Any, ids: dict[type[Any], str] +) -> dict[str, Any]: + definition = definition_from_workflow(workflow, workflow_id, ids, describe=True) + return { + "id": workflow_id, + "name": workflow.name, + "definition": definition, + "describe": definition["describe"], + "editable": definition["editable"], + "readonly_reasons": definition["readonly_reasons"], + } + + +def build_catalog(*, include_workflows: bool = True) -> dict[str, Any]: + from taskmaestro.discovery import load_registry + + registry = load_registry(include_workflows=include_workflows) + errors = [_error(problem) for problem in registry.problems] + descriptors: list[dict[str, Any]] = [] + for task_id, cls in sorted(registry.tasks.items()): + try: + descriptors.append(task_descriptor(task_id, cls)) + except Exception as exc: # noqa: BLE001 - report plugin errors, keep the rest + errors.append( + { + "kind": "task", + "id": task_id, + "entry_point": "", + "message": f"Cannot describe task: {exc}", + } + ) + + workflows: list[dict[str, Any]] = [] + ids = class_ids(registry.tasks) + for workflow_id, workflow in sorted(registry.workflows.items()): + try: + workflows.append(_workflow_entry(workflow_id, workflow, ids)) + except Exception as exc: # noqa: BLE001 - report plugin errors, keep the rest + errors.append( + { + "kind": "workflow", + "id": workflow_id, + "entry_point": "", + "message": str(exc), + } + ) + + return { + "format": CATALOG_FORMAT, + "status": "ok", + "taskmaestro_version": taskmaestro.__version__, + "tasks": descriptors, + "workflows": workflows, + "errors": errors, + } + + +def export_registered(workflow_id: str, *, describe: bool = False) -> dict[str, Any]: + from taskmaestro.discovery import get_registered_workflow, load_registry + from taskmaestro.exceptions import PluginLoadError + + try: + workflow = get_registered_workflow(workflow_id) + except PluginLoadError as exc: + raise DefinitionError(str(exc)) from exc + tasks = load_registry(include_workflows=False).tasks + return definition_from_workflow( + workflow, workflow_id, class_ids(tasks), describe=describe + ) + + +def catalog_main(argv: list[str]) -> dict[str, Any]: + import argparse + + parser = argparse.ArgumentParser( + prog="catalog", description="List installed taskmaestro tasks and workflows." + ) + parser.add_argument("--no-workflows", action="store_true") + args = parser.parse_args(argv) + return build_catalog(include_workflows=not args.no_workflows) + + +def export_registered_main(argv: list[str]) -> dict[str, Any]: + import argparse + + parser = argparse.ArgumentParser( + prog="export-registered", + description="Convert a registered workflow to a workflow definition.", + ) + parser.add_argument("workflow_id") + parser.add_argument("--describe", action="store_true") + args = parser.parse_args(argv) + result = export_registered(args.workflow_id, describe=args.describe) + result["status"] = "ok" + return result diff --git a/GrpcInterface/Python/rips/taskmaestro_helper/definition.py b/GrpcInterface/Python/rips/taskmaestro_helper/definition.py new file mode 100644 index 0000000000..6c0fd4c232 --- /dev/null +++ b/GrpcInterface/Python/rips/taskmaestro_helper/definition.py @@ -0,0 +1,117 @@ +"""Workflow definition subcommands (``load`` and ``save``). + +The conversion between workflow folders and editable definitions is done by +``taskmaestro.definition``. This module adds ResInsight's schema annotations and +makes sure object references, which are only valid for one run, are never saved. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +from taskmaestro import definition as tm_definition +from taskmaestro.exceptions import DefinitionError + +from .refs import strip_refs +from .schema import ResInsightSchemaGenerator + +DEFINITION_FORMAT = tm_definition.DEFINITION_FORMAT +WORKFLOW_FILE = tm_definition.WORKFLOW_FILE +INPUT_FILE = tm_definition.INPUT_FILE + + +def load_definition(workflow_dir: Path, *, describe: bool = False) -> dict[str, Any]: + """Load ``workflow.yaml`` (and ``input.yaml``) from a workflow folder.""" + return tm_definition.load_definition( + workflow_dir, describe=describe, schema_generator=ResInsightSchemaGenerator + ) + + +def definition_from_workflow( + workflow: Any, + registered_id: str | None, + class_ids: dict[type[Any], str], + *, + describe: bool = False, +) -> dict[str, Any]: + """Convert an in-memory workflow to a definition.""" + return tm_definition.definition_from_workflow( + workflow, + registered_id, + class_ids, + describe=describe, + schema_generator=ResInsightSchemaGenerator, + ) + + +def describe_yaml( + workflow_yaml: Path, input_yaml: Path | None = None +) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: + """Describe a YAML workflow. Returns ``(description, error)``.""" + return tm_definition.describe_yaml( + workflow_yaml, input_yaml, ResInsightSchemaGenerator + ) + + +def write_definition( + definition: dict[str, Any], + out_dir: Path, + *, + workflow_only: bool = False, + backup: bool = False, +) -> dict[str, Any]: + """Write ``workflow.yaml`` (and ``input.yaml``) for a definition.""" + return tm_definition.write_definition( + definition, + out_dir, + workflow_only=workflow_only, + backup=backup, + value_filter=strip_refs, + ) + + +# --- Command-line entry points --- + + +def load_main(argv: list[str]) -> dict[str, Any]: + parser = argparse.ArgumentParser(prog="load", description="Load a workflow folder.") + parser.add_argument("workflow_dir", type=Path) + parser.add_argument("--describe", action="store_true") + args = parser.parse_args(argv) + result = load_definition(args.workflow_dir, describe=args.describe) + result["status"] = "ok" + return result + + +def save_main(argv: list[str], stdin_text: str) -> dict[str, Any]: + parser = argparse.ArgumentParser( + prog="save", + description="Write a workflow definition (read from stdin) as YAML.", + ) + parser.add_argument("--out-dir", type=Path, required=True) + parser.add_argument("--workflow-only", action="store_true") + parser.add_argument("--describe", action="store_true") + parser.add_argument("--backup", action="store_true") + args = parser.parse_args(argv) + try: + definition = json.loads(stdin_text) + except json.JSONDecodeError as exc: + raise DefinitionError(f"Invalid definition JSON: {exc}") from exc + if not isinstance(definition, dict): + raise DefinitionError("The definition must be a JSON object") + + result = write_definition( + definition, + args.out_dir, + workflow_only=args.workflow_only, + backup=args.backup, + ) + if args.describe: + input_path = Path(result["input_yaml"]) if result["input_yaml"] else None + result["describe"], result["describe_error"] = describe_yaml( + Path(result["workflow_yaml"]), input_path + ) + return result diff --git a/GrpcInterface/Python/rips/taskmaestro_helper/refs.py b/GrpcInterface/Python/rips/taskmaestro_helper/refs.py index afa2d8f030..830115919c 100644 --- a/GrpcInterface/Python/rips/taskmaestro_helper/refs.py +++ b/GrpcInterface/Python/rips/taskmaestro_helper/refs.py @@ -8,5 +8,33 @@ maps back to live `rips` objects. The C++ side maps Python types to the from __future__ import annotations +from typing import Any + # Marker used inside input.yaml to denote a ResInsight object reference. REF_MARKER = "__resinsight_ref__" + + +def _is_ref(value: Any) -> bool: + return isinstance(value, dict) and REF_MARKER in value + + +def _holds_ref(value: Any) -> bool: + """A reference, or a list with a reference among its items.""" + return _is_ref(value) or ( + isinstance(value, list) and any(_holds_ref(item) for item in value) + ) + + +def strip_refs(value: Any) -> Any: + """Remove ResInsight object references, which are only valid for one run. + + A list that holds a reference is removed as a whole: dropping only the + reference would shorten the list and change what the remaining items mean. + """ + if isinstance(value, dict): + return { + key: strip_refs(item) for key, item in value.items() if not _holds_ref(item) + } + if isinstance(value, list): + return [strip_refs(item) for item in value if not _holds_ref(item)] + return value diff --git a/GrpcInterface/Python/rips/taskmaestro_helper/run.py b/GrpcInterface/Python/rips/taskmaestro_helper/run.py index 8ed05d409e..dd1ed6d73d 100644 --- a/GrpcInterface/Python/rips/taskmaestro_helper/run.py +++ b/GrpcInterface/Python/rips/taskmaestro_helper/run.py @@ -15,19 +15,25 @@ from __future__ import annotations import json import sys +from collections.abc import Callable from pathlib import Path -from typing import Any, Callable +from typing import Any import yaml from .refs import REF_MARKER - PROGRESS_PREFIX = "@@RI_WORKFLOW_EVENT@@" -def _emit_progress(run_id: str, task: str, state: str, error: str = "") -> None: +def _emit_progress( + run_id: str, task: str, state: str, error: str = "", item: str | None = None +) -> None: payload = {"event": "task_state", "run_id": run_id, "task": task, "state": state} + if item is not None: + # One item of a mapped task (`map:`); the task itself reports its own states + payload["event"] = "map_item_state" + payload["item"] = item if error: payload["error"] = error sys.stdout.write(PROGRESS_PREFIX + json.dumps(payload) + "\n") @@ -49,6 +55,15 @@ class ProgressHook: def on_task_fail(self, job: Any, task: Any, error: Exception) -> None: _emit_progress(self.run_id, task.name, "failed", str(error)) + def on_map_item_start(self, job: Any, task: Any, key: str) -> None: + _emit_progress(self.run_id, task.name, "running", item=key) + + def on_map_item_complete(self, job: Any, task: Any, key: str, output: Any) -> None: + _emit_progress(self.run_id, task.name, "completed", item=key) + + def on_map_item_fail(self, job: Any, task: Any, key: str, error: Exception) -> None: + _emit_progress(self.run_id, task.name, "failed", str(error), item=key) + def _emit(event: str, **fields: Any) -> None: sys.stdout.write(json.dumps({"event": event, **fields}) + "\n") @@ -91,12 +106,31 @@ def make_rips_resolver(rips_instance: Any) -> Callable[[str, dict[str, Any]], An return resolve +def _load_workflow(workflow_dir: Path | None, registered_id: str | None) -> Any: + if registered_id is not None: + from taskmaestro.discovery import get_registered_workflow + + return get_registered_workflow(registered_id) + + from taskmaestro.yaml_config import load_workflow_only + + assert workflow_dir is not None + workflow, _ = load_workflow_only(workflow_dir / "workflow.yaml") + return workflow + + def run_workflow( - workflow_dir: Path, input_path: Path, grpc_port: int, run_id: str = "" + workflow_dir: Path | None, + input_path: Path, + grpc_port: int, + run_id: str = "", + registered_id: str | None = None, ) -> int: - workflow_dir_str = str(workflow_dir) - if workflow_dir_str not in sys.path: - sys.path.insert(0, workflow_dir_str) + """Run a YAML workflow folder, or a registered workflow by its id.""" + if workflow_dir is not None: + workflow_dir_str = str(workflow_dir) + if workflow_dir_str not in sys.path: + sys.path.insert(0, workflow_dir_str) import rips @@ -114,15 +148,17 @@ def run_workflow( resolver = make_rips_resolver(instance) try: resolved = resolve_refs(raw_input, resolver) - except Exception as exc: + except Exception as exc: # noqa: BLE001 - reported as an error event _emit("error", message=f"Failed to resolve references: {exc}") return 1 from taskmaestro import EmptyConfig, ExecutionContext, Job, JobConfiguration, Runner - from taskmaestro.yaml_config import _load_workflow_only - workflow_yaml = workflow_dir / "workflow.yaml" - workflow, _ = _load_workflow_only(workflow_yaml) + try: + workflow = _load_workflow(workflow_dir, registered_id) + except Exception as exc: # noqa: BLE001 - reported as an error event + _emit("error", message=f"Failed to load workflow: {exc}") + return 1 job_config = JobConfiguration(resolved) job: Job[Any] = Job( @@ -132,7 +168,7 @@ def run_workflow( _emit("starting", workflow=workflow.name) try: result = Runner(hooks=[ProgressHook(run_id)]).run(job, ctx=ExecutionContext()) - except Exception as exc: + except Exception as exc: # noqa: BLE001 - reported as an error event _emit("error", message=str(exc)) return 1 @@ -146,11 +182,20 @@ def main(argv: list[str]) -> int: parser = argparse.ArgumentParser( prog="run", description="Run a taskmaestro workflow." ) - parser.add_argument("workflow_dir", type=Path) + parser.add_argument("workflow_dir", type=Path, nargs="?") + parser.add_argument( + "--registered", metavar="ID", help="Run a registered workflow by its id" + ) parser.add_argument("--input", type=Path, required=True) parser.add_argument("--grpc-port", type=int, required=True) parser.add_argument("--run-id", default="") args = parser.parse_args(argv) + if (args.workflow_dir is None) == (args.registered is None): + parser.error("specify either a workflow folder or --registered ") return run_workflow( - args.workflow_dir.resolve(), args.input.resolve(), args.grpc_port, args.run_id + args.workflow_dir.resolve() if args.workflow_dir is not None else None, + args.input.resolve(), + args.grpc_port, + args.run_id, + registered_id=args.registered, ) diff --git a/GrpcInterface/Python/rips/taskmaestro_helper/schema.py b/GrpcInterface/Python/rips/taskmaestro_helper/schema.py new file mode 100644 index 0000000000..eafb6bfd52 --- /dev/null +++ b/GrpcInterface/Python/rips/taskmaestro_helper/schema.py @@ -0,0 +1,56 @@ +"""JSON schemas for task input/output types, as consumed by ResInsight. + +taskmaestro's ``TaskSchemaGenerator`` marks opaque Python objects as +``{"not": {}, "x-taskmaestro-opaque": true, ...}``. This module adds +``x-ri-python-type``/``x-ri-python-bases`` to models and opaque types. The bases +list the class MRO so ResInsight can check port compatibility the same way +taskmaestro does (``issubclass``) without importing anything. +""" + +from __future__ import annotations + +import inspect +from typing import Any + +from pydantic import BaseModel +from taskmaestro.introspection import TaskSchemaGenerator, python_type_name +from taskmaestro.introspection import model_json_schema as tm_model_json_schema +from taskmaestro.introspection import task_descriptor as tm_task_descriptor + +_IGNORED_BASES = {object, BaseModel} + + +def python_bases(cls: type[Any]) -> list[str]: + """Return the dotted names of ``cls`` and its bases, most derived first.""" + names: list[str] = [] + for base in inspect.getmro(cls): + if base in _IGNORED_BASES or base.__module__ == "typing": + continue + name = python_type_name(base) + if name not in names: + names.append(name) + return names + + +def _type_annotations(cls: type[Any]) -> dict[str, Any]: + return { + "x-ri-python-type": python_type_name(cls), + "x-ri-python-bases": python_bases(cls), + } + + +class ResInsightSchemaGenerator(TaskSchemaGenerator): + """Schema generator that records Python class bases for port compatibility.""" + + @classmethod + def type_annotations(cls, python_type: type[Any]) -> dict[str, Any]: + return _type_annotations(python_type) + + +def model_json_schema(model: type[Any]) -> dict[str, Any]: + return tm_model_json_schema(model, ResInsightSchemaGenerator) + + +def task_descriptor(task_id: str, cls: type[Any]) -> dict[str, Any]: + """Describe a task class for the ResInsight task palette and port typing.""" + return tm_task_descriptor(task_id, cls, ResInsightSchemaGenerator) diff --git a/GrpcInterface/Python/rips/tests/test_data/taskmaestro_workflows/resinsight_completions/input.yaml b/GrpcInterface/Python/rips/tests/test_data/taskmaestro_workflows/resinsight_completions/input.yaml new file mode 100644 index 0000000000..51be760c0e --- /dev/null +++ b/GrpcInterface/Python/rips/tests/test_data/taskmaestro_workflows/resinsight_completions/input.yaml @@ -0,0 +1,22 @@ +# Per-task input data for the ResInsight Completions Pipeline +# +# Each top-level key is a task name, with its configuration values. +# `case` (select_eclipse_case) and `well_path` (select_well_path_*) are +# runtime-picker fields supplied by the caller — not literal values here. + +connect_to_resinsight: {} +select_eclipse_case: {} +select_well_path_1: {} +select_well_path_2: {} +add_perf_1: + event_date: "2024-01-01" + start_md: 3000.0 + end_md: 3500.0 +add_perf_2: + event_date: "2024-02-01" + start_md: 2400.0 + end_md: 2600.0 + +export_completions: + event_date: "2024-05-01" + export_path: "/tmp/completions.txt" diff --git a/GrpcInterface/Python/rips/tests/test_data/taskmaestro_workflows/resinsight_completions/workflow.yaml b/GrpcInterface/Python/rips/tests/test_data/taskmaestro_workflows/resinsight_completions/workflow.yaml new file mode 100644 index 0000000000..5ea52dedd4 --- /dev/null +++ b/GrpcInterface/Python/rips/tests/test_data/taskmaestro_workflows/resinsight_completions/workflow.yaml @@ -0,0 +1,52 @@ +# ResInsight Completions Pipeline — YAML workflow configuration +# +# Tasks use stable entry-point identifiers published by taskmaestro-resinsight, +# rather than Python module/class paths. +# +# DAG with fan-out / fan-in pattern: +# ConnectToResInsight → SelectEclipseCase → [SelectWellPath x2] → [AddPerforation x2] → ExportCompletions + +workflow: + name: resinsight_completions + tasks: + - task: resinsight.connect + - task: resinsight.select_eclipse_case + depends_on: + resinsight: resinsight.connect + config_fields: [case] + - task: resinsight.select_well_path + name: select_well_path_1 + depends_on: + resinsight: resinsight.connect + grid_case: resinsight.select_eclipse_case + config_fields: [well_path] + - task: resinsight.select_well_path + name: select_well_path_2 + depends_on: + resinsight: resinsight.connect + grid_case: resinsight.select_eclipse_case + config_fields: [well_path] + - task: resinsight.add_perforation + name: add_perf_1 + depends_on: + resinsight: resinsight.connect + well_path: select_well_path_1 + config_fields: [event_date, start_md, end_md] + - task: resinsight.add_perforation + name: add_perf_2 + depends_on: + resinsight: resinsight.connect + well_path: select_well_path_2 + config_fields: [event_date, start_md, end_md] + - task: resinsight.export_completions + depends_on: + resinsight: resinsight.connect + grid_case: resinsight.select_eclipse_case + perforation_1: add_perf_1 + perforation_2: add_perf_2 + config_fields: [event_date, export_path] + +runner: + hooks: + - hook: taskmaestro.hooks.logging.LoggingHook + - hook: taskmaestro.hooks.timing.TimingHook diff --git a/GrpcInterface/Python/rips/tests/test_data/taskmaestro_workflows/resinsight_load_data/input.yaml b/GrpcInterface/Python/rips/tests/test_data/taskmaestro_workflows/resinsight_load_data/input.yaml new file mode 100644 index 0000000000..87ad0532d2 --- /dev/null +++ b/GrpcInterface/Python/rips/tests/test_data/taskmaestro_workflows/resinsight_load_data/input.yaml @@ -0,0 +1,10 @@ +# Paths are supplied for the selected ROFF grid, its property file, and surface. + +connect_to_resinsight: {} +load_model: + path: /path/to/grid.roff +import_grid_property: + paths: + - /path/to/property.roffasc +load_regular_surface: + path: /path/to/surface.irap diff --git a/GrpcInterface/Python/rips/tests/test_data/taskmaestro_workflows/resinsight_load_data/workflow.yaml b/GrpcInterface/Python/rips/tests/test_data/taskmaestro_workflows/resinsight_load_data/workflow.yaml new file mode 100644 index 0000000000..c0a0270e9d --- /dev/null +++ b/GrpcInterface/Python/rips/tests/test_data/taskmaestro_workflows/resinsight_load_data/workflow.yaml @@ -0,0 +1,20 @@ +# ResInsight grid, property, and regular surface loading example. + +workflow: + name: resinsight_load_data + result_task: resinsight.import_grid_property + tasks: + - task: resinsight.connect + - task: resinsight.load_model + depends_on: + resinsight: resinsight.connect + config_fields: [path] + - task: resinsight.import_grid_property + depends_on: + resinsight: resinsight.connect + grid_case: resinsight.load_model + config_fields: [paths] + - task: resinsight.load_regular_surface + depends_on: + resinsight: resinsight.connect + config_fields: [path] diff --git a/GrpcInterface/Python/rips/tests/test_data/taskmaestro_workflows/tm_helper_pipeline.py b/GrpcInterface/Python/rips/tests/test_data/taskmaestro_workflows/tm_helper_pipeline.py new file mode 100644 index 0000000000..4bf325c88b --- /dev/null +++ b/GrpcInterface/Python/rips/tests/test_data/taskmaestro_workflows/tm_helper_pipeline.py @@ -0,0 +1,79 @@ +"""Small taskmaestro tasks used by the taskmaestro helper tests.""" + +from __future__ import annotations + +from pydantic import BaseModel +from taskmaestro import EmptyConfig, ExecutionContext, Task, Workflow + + +class Number(BaseModel): + """A single number.""" + + value: float + + +class StartInput(BaseModel): + """Configured start value.""" + + value: float + + +class SpecialNumber(Number): + """A number subclass, compatible with Number.""" + + +class Start(Task[StartInput, Number]): + """Produce the configured value.""" + + name = "start" + + def run(self, input: StartInput, ctx: ExecutionContext) -> Number: + return Number(value=input.value) + + +class Constant(Task[EmptyConfig, Number]): + """Produce the number one.""" + + name = "constant" + + def run(self, input: EmptyConfig, ctx: ExecutionContext) -> Number: + return Number(value=1.0) + + +class Double(Task[Number, Number]): + """Double a number.""" + + name = "double" + + def run(self, input: Number, ctx: ExecutionContext) -> Number: + return Number(value=2 * input.value) + + +class Special(Task[Number, SpecialNumber]): + """Produce a SpecialNumber.""" + + name = "special" + + def run(self, input: Number, ctx: ExecutionContext) -> SpecialNumber: + return SpecialNumber(value=input.value) + + +registered_workflow = ( + Workflow.builder("registered_flow") + .add_task(Start, config_fields=["value"]) + .add_task(Double, depends_on=Start) + .build() +) + + +def _local_workflow() -> Workflow: + class LocalTask(Task[EmptyConfig, Number]): + name = "local" + + def run(self, input: EmptyConfig, ctx: ExecutionContext) -> Number: + return Number(value=0.0) + + return Workflow.builder("local_flow").add_task(LocalTask).build() + + +local_workflow = _local_workflow() diff --git a/GrpcInterface/Python/rips/tests/test_taskmaestro_helper.py b/GrpcInterface/Python/rips/tests/test_taskmaestro_helper.py index 81b2be4f11..4252074631 100644 --- a/GrpcInterface/Python/rips/tests/test_taskmaestro_helper.py +++ b/GrpcInterface/Python/rips/tests/test_taskmaestro_helper.py @@ -14,6 +14,22 @@ import pytest pytest.importorskip("taskmaestro") +def test_strip_refs_drops_references_and_lists_holding_them() -> None: + from rips.taskmaestro_helper.refs import strip_refs + + ref = {"__resinsight_ref__": "EclipseCase", "case_id": 0} + inputs = { + "start": {"case": ref, "count": 1}, + "sum": {"cases": [ref, ref], "mixed": [1.0, ref], "values": [1.0, 2.0]}, + "only_refs": {"case": ref}, + } + assert strip_refs(inputs) == { + "start": {"count": 1}, + "sum": {"values": [1.0, 2.0]}, + "only_refs": {}, + } + + def test_resolve_refs_substitutes_object_model_value() -> None: from rips.taskmaestro_helper.run import resolve_refs @@ -73,9 +89,8 @@ def test_progress_hook_reports_named_task_instances( def test_progress_hook_follows_runner_instance_names( capsys: pytest.CaptureFixture[str], ) -> None: - from taskmaestro import EmptyConfig, ExecutionContext, Job, Runner, Task, Workflow - from rips.taskmaestro_helper.run import PROGRESS_PREFIX, ProgressHook + from taskmaestro import EmptyConfig, ExecutionContext, Job, Runner, Task, Workflow class Step(Task[EmptyConfig, EmptyConfig]): name = "step" @@ -108,9 +123,8 @@ def test_progress_hook_follows_runner_instance_names( def test_progress_hook_reports_runner_failure( capsys: pytest.CaptureFixture[str], ) -> None: - from taskmaestro import EmptyConfig, ExecutionContext, Job, Runner, Task, Workflow - from rips.taskmaestro_helper.run import PROGRESS_PREFIX, ProgressHook + from taskmaestro import EmptyConfig, ExecutionContext, Job, Runner, Task, Workflow class Fails(Task[EmptyConfig, EmptyConfig]): name = "fails" @@ -135,11 +149,67 @@ def test_progress_hook_reports_runner_failure( assert events[-1]["error"] == "bad range" +def test_progress_hook_reports_mapped_items( + capsys: pytest.CaptureFixture[str], +) -> None: + from pydantic import BaseModel + from rips.taskmaestro_helper.run import PROGRESS_PREFIX, ProgressHook + from taskmaestro import EmptyConfig, ExecutionContext, Job, Runner, Task, Workflow + from taskmaestro.job import JobConfiguration + from taskmaestro.mapping import TaskMap + + class Item(BaseModel): + key: str + value: int + + class Out(BaseModel): + value: int + + class Check(Task[Item, Out]): + name = "check" + + def run(self, input: Item, ctx: ExecutionContext) -> Out: + if input.value < 0: + raise ValueError("negative") + return Out(value=input.value) + + workflow = ( + Workflow.builder("mapped") + .add_task( + Check, + name="each", + mapped_over=TaskMap( + over="items", key_as="key", value_as="value", error_mode="collect_all" + ), + ) + .build() + ) + config = JobConfiguration({"each": {"items": {"a": 1, "b": -1}}}) + Runner(hooks=[ProgressHook("run-map")]).run( + Job(workflow, EmptyConfig(), job_configuration=config), ctx=ExecutionContext() + ) + events = [ + json.loads(line.removeprefix(PROGRESS_PREFIX)) + for line in capsys.readouterr().out.splitlines() + if line.startswith(PROGRESS_PREFIX) + ] + assert [ + (event["event"], event.get("item"), event["state"]) for event in events + ] == [ + ("task_state", None, "running"), + ("map_item_state", "a", "running"), + ("map_item_state", "a", "completed"), + ("map_item_state", "b", "running"), + ("map_item_state", "b", "failed"), + ("task_state", None, "failed"), + ] + assert events[4]["error"] == "negative" + + def test_run_helper_emits_progress_without_grpc( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: import rips - from rips.taskmaestro_helper.run import PROGRESS_PREFIX, run_workflow (tmp_path / "progress_pipeline.py").write_text( diff --git a/GrpcInterface/Python/rips/tests/test_taskmaestro_helper_definition.py b/GrpcInterface/Python/rips/tests/test_taskmaestro_helper_definition.py new file mode 100644 index 0000000000..b6363f2f15 --- /dev/null +++ b/GrpcInterface/Python/rips/tests/test_taskmaestro_helper_definition.py @@ -0,0 +1,385 @@ +"""Tests for the workflow definition subcommands of rips.taskmaestro_helper. + +Run with ``pytest --noconftest``: these tests do not need a running ResInsight. +""" + +from __future__ import annotations + +import json +import shutil +import textwrap +from importlib.metadata import EntryPoint +from pathlib import Path +from typing import Any + +import pytest + +pytest.importorskip("taskmaestro") + +from rips.taskmaestro_helper import __main__ as helper_main +from rips.taskmaestro_helper.catalog import build_catalog, export_registered +from rips.taskmaestro_helper.definition import ( + describe_yaml, + load_definition, + write_definition, +) + +TEST_DATA = Path(__file__).parent / "test_data" / "taskmaestro_workflows" +PIPELINE = "tm_helper_pipeline" + +STRUCTURAL_KEYS = ( + "name", + "result_task", + "nodes", + "edges", + "inputs", + "editable", + "header_comment", + "passthrough", + "task_types", +) + + +@pytest.fixture(autouse=True) +def pipeline_on_path(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.syspath_prepend(str(TEST_DATA)) + + +def _fake_entry_points( + monkeypatch: pytest.MonkeyPatch, + tasks: dict[str, str], + workflows: dict[str, str] | None = None, +) -> None: + from taskmaestro import discovery + + groups = { + discovery.TASK_ENTRY_POINT_GROUP: [ + EntryPoint(name, value, discovery.TASK_ENTRY_POINT_GROUP) + for name, value in tasks.items() + ], + discovery.WORKFLOW_ENTRY_POINT_GROUP: [ + EntryPoint(name, value, discovery.WORKFLOW_ENTRY_POINT_GROUP) + for name, value in (workflows or {}).items() + ], + } + + def entry_points(*, group: str) -> list[EntryPoint]: + return groups.get(group, []) + + monkeypatch.setattr(discovery, "entry_points", entry_points) + + +@pytest.fixture +def plugins(monkeypatch: pytest.MonkeyPatch) -> None: + _fake_entry_points( + monkeypatch, + tasks={ + "test.start": f"{PIPELINE}:Start", + "test.double": f"{PIPELINE}:Double", + "test.constant": f"{PIPELINE}:Constant", + "test.broken": "tm_helper_missing_module:Broken", + }, + workflows={ + "test.flow": f"{PIPELINE}:registered_workflow", + "test.local": f"{PIPELINE}:local_workflow", + }, + ) + + +def _write_workflow(directory: Path, workflow: str, inputs: str | None = None) -> Path: + directory.mkdir(parents=True, exist_ok=True) + (directory / "workflow.yaml").write_text(textwrap.dedent(workflow)) + if inputs is not None: + (directory / "input.yaml").write_text(textwrap.dedent(inputs)) + return directory + + +def _structure(definition: dict[str, Any]) -> dict[str, Any]: + return {key: definition[key] for key in STRUCTURAL_KEYS} + + +def _describe_summary(description: dict[str, Any]) -> dict[str, Any]: + return { + "result_task": description["result_task"], + "tasks": [ + (task["name"], task["depends_on"], task["config_fields"]) + for task in description["tasks"] + ], + } + + +def _round_trip(source: Path, out_dir: Path) -> tuple[dict[str, Any], dict[str, Any]]: + first = load_definition(source, describe=True) + assert first["editable"], first["readonly_reasons"] + assert first["describe_error"] is None, first["describe_error"] + write_definition(first, out_dir) + second = load_definition(out_dir, describe=True) + assert second["describe_error"] is None, second["describe_error"] + assert _structure(second) == _structure(first) + assert _describe_summary(second["describe"]) == _describe_summary(first["describe"]) + third_dir = out_dir.parent / (out_dir.name + "_again") + write_definition(second, third_dir) + assert (third_dir / "workflow.yaml").read_text() == ( + out_dir / "workflow.yaml" + ).read_text() + return first, second + + +# --- Catalog --- + + +def test_catalog_reports_broken_plugin_and_loads_the_rest(plugins: None) -> None: + catalog = build_catalog() + ids = [task["id"] for task in catalog["tasks"]] + assert ids == ["test.constant", "test.double", "test.start"] + assert [error["id"] for error in catalog["errors"]] == ["test.broken"] + + start = next(task for task in catalog["tasks"] if task["id"] == "test.start") + assert start["name"] == "start" + assert start["description"] == "Produce the configured value." + assert start["python_type"] == f"{PIPELINE}.Start" + assert start["input_schema"]["properties"]["value"]["type"] == "number" + + workflows = {workflow["id"]: workflow for workflow in catalog["workflows"]} + assert set(workflows) == {"test.flow", "test.local"} + assert workflows["test.flow"]["editable"] + assert not workflows["test.local"]["editable"] + assert workflows["test.flow"]["describe"]["result_task"] == "double" + + +def test_catalog_without_workflows(plugins: None) -> None: + assert build_catalog(include_workflows=False)["workflows"] == [] + + +def test_catalog_cli_prints_one_json_document( + plugins: None, capsys: pytest.CaptureFixture[str] +) -> None: + assert helper_main.main(["catalog", "--no-workflows"]) == 0 + out = capsys.readouterr().out + document = json.loads(out) + assert document["status"] == "ok" + assert len(document["tasks"]) == 3 + + +def test_cli_reports_missing_taskmaestro( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + def missing(argv: list[str]) -> dict[str, Any]: + raise ModuleNotFoundError("No module named 'taskmaestro'", name="taskmaestro") + + monkeypatch.setitem(helper_main.JSON_COMMANDS, "catalog", missing) + assert helper_main.main(["catalog"]) == 2 + error = json.loads(capsys.readouterr().out)["error"] + assert error["message"].startswith("taskmaestro>=0.4,<0.5 is required") + + +def test_cli_failure_prints_invalid_document( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + assert helper_main.main(["load", str(tmp_path / "missing")]) == 2 + document = json.loads(capsys.readouterr().out) + assert document["status"] == "invalid" + assert "Cannot read" in document["error"]["message"] + + +# --- Schema --- + + +def test_schema_records_python_bases() -> None: + from rips.taskmaestro_helper.schema import task_descriptor + from tm_helper_pipeline import Special + + descriptor = task_descriptor("test.special", Special) + output = descriptor["output_schema"] + assert output["x-ri-python-type"] == f"{PIPELINE}.SpecialNumber" + assert output["x-ri-python-bases"] == [ + f"{PIPELINE}.SpecialNumber", + f"{PIPELINE}.Number", + ] + + +def test_schema_perforation_output_is_not_a_well_path() -> None: + pytest.importorskip("taskmaestro_resinsight") + from rips.taskmaestro_helper.schema import task_descriptor + from taskmaestro import get_registered_task + + add_perforation = task_descriptor( + "resinsight.add_perforation", get_registered_task("resinsight.add_perforation") + ) + output_bases = add_perforation["output_schema"]["x-ri-python-bases"] + well_path = add_perforation["input_schema"]["$defs"]["WellPath"] + assert well_path["x-ri-python-type"] == "taskmaestro_resinsight.models.WellPath" + assert well_path["x-ri-python-type"] not in output_bases + opaque = well_path["properties"]["value"] + assert opaque["x-taskmaestro-opaque"] is True + assert "rips.generated.generated_classes.WellPath" in opaque["x-ri-python-bases"] + + +# --- Round trips --- + + +def test_round_trip_whole_output_dependency(tmp_path: Path) -> None: + source = _write_workflow( + tmp_path / "src", + f"""\ + workflow: + name: whole + tasks: + - task: {PIPELINE}.Start + config_fields: [value] + - task: {PIPELINE}.Double + depends_on: {PIPELINE}.Start + """, + "start:\n value: 2.5\n", + ) + first, _ = _round_trip(source, tmp_path / "out") + assert first["edges"] == [ + {"from": "start", "output": None, "to": "double", "input": None} + ] + assert first["inputs"] == {"start": {"value": 2.5}, "double": {}} + text = (tmp_path / "out" / "workflow.yaml").read_text() + assert "name: start" in text and "depends_on: start" in text + assert "result_task" not in text + + +# --- References --- + + +def test_saved_input_drops_resinsight_refs(tmp_path: Path) -> None: + source = _write_workflow( + tmp_path / "src", + f"""\ + workflow: + name: with_ref + tasks: + - task: {PIPELINE}.Start + config_fields: [value] + """, + "start:\n value: {__resinsight_ref__: EclipseCase, case_id: 0}\n", + ) + write_definition(load_definition(source), tmp_path / "out") + assert (tmp_path / "out" / "input.yaml").read_text() == "start: {}\n" + + +def test_save_cli_reads_definition_from_stdin( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + import io + + definition = load_definition( + _write_workflow( + tmp_path / "src", + f"""\ + workflow: + name: via_cli + tasks: + - task: {PIPELINE}.Start + config_fields: [value] + """, + ) + ) + monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps(definition))) + out_dir = tmp_path / "out" + assert helper_main.main(["save", "--out-dir", str(out_dir), "--describe"]) == 0 + result = json.loads(capsys.readouterr().out) + assert result["status"] == "ok" + assert result["describe"]["tasks"][0]["missing_config_fields"] == ["value"] + assert (out_dir / "input.yaml").read_text() == "start: {}\n" + + +# --- Registered workflows --- + + +def test_export_registered(plugins: None) -> None: + definition = export_registered("test.flow", describe=True) + assert definition["editable"] + assert definition["source"]["registered_id"] == "test.flow" + assert definition["nodes"] == [ + {"name": "start", "task": "test.start", "config_fields": ["value"]}, + {"name": "double", "task": "test.double", "config_fields": []}, + ] + assert definition["edges"] == [ + {"from": "start", "output": None, "to": "double", "input": None} + ] + assert definition["result_task"] is None + assert definition["describe"]["result_task"] == "double" + + +def test_export_registered_local_class_is_read_only(plugins: None) -> None: + definition = export_registered("test.local") + assert not definition["editable"] + assert "cannot be referenced" in definition["readonly_reasons"][0] + + +def test_exported_registered_workflow_saves_and_validates( + plugins: None, tmp_path: Path +) -> None: + definition = export_registered("test.flow") + definition["inputs"] = {"start": {"value": 3}} + write_definition(definition, tmp_path / "out") + description, error = describe_yaml( + tmp_path / "out" / "workflow.yaml", tmp_path / "out" / "input.yaml" + ) + assert error is None + assert description is not None and description["result_task"] == "double" + + +def test_run_registered_workflow( + plugins: None, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + import rips + from rips.taskmaestro_helper.run import PROGRESS_PREFIX, main + + input_path = tmp_path / "input.yaml" + input_path.write_text("start:\n value: 2\n") + monkeypatch.setattr(rips.Instance, "find", lambda **kwargs: object()) + + assert ( + main( + [ + "--registered", + "test.flow", + "--input", + str(input_path), + "--grpc-port", + "1234", + "--run-id", + "rid", + ] + ) + == 0 + ) + events = [ + json.loads(line.removeprefix(PROGRESS_PREFIX)) + for line in capsys.readouterr().out.splitlines() + if line.startswith(PROGRESS_PREFIX) + ] + assert [(event["task"], event["state"]) for event in events] == [ + ("start", "running"), + ("start", "completed"), + ("double", "running"), + ("double", "completed"), + ] + + +def test_run_requires_folder_or_registered_id(tmp_path: Path) -> None: + from rips.taskmaestro_helper.run import main + + with pytest.raises(SystemExit): + main(["--input", str(tmp_path / "input.yaml"), "--grpc-port", "1"]) + + +# --- The taskmaestro-resinsight examples --- + + +@pytest.mark.parametrize("name", ["resinsight_completions", "resinsight_load_data"]) +def test_example_workflows_round_trip(tmp_path: Path, name: str) -> None: + pytest.importorskip("taskmaestro_resinsight") + source = tmp_path / name + shutil.copytree(TEST_DATA / name, source) + first, _ = _round_trip(source, tmp_path / "out") + assert first["warnings"] == [] + assert all(node["task"].startswith("resinsight.") for node in first["nodes"]) diff --git a/README-experimental-workflow-ui.md b/README-experimental-workflow-ui.md index ad6201fcfe..22b398de40 100644 --- a/README-experimental-workflow-ui.md +++ b/README-experimental-workflow-ui.md @@ -34,18 +34,27 @@ git clone https://github.com/OPM/taskmaestro-resinsight.git \ python -m pip install -e ~/resinsight-workflows/taskmaestro-resinsight ``` -ResInsight reads workflows with `python -m taskmaestro workflow describe`, which -needs a `taskmaestro` version that reports `config_values` -([OPM/taskmaestro#13](https://github.com/OPM/taskmaestro/pull/13)). Until that -is released, install it from Git: +The Workflow UI requires **taskmaestro 0.4.x** (0.4.0 or newer, below 0.5) and +**taskmaestro-resinsight 0.3.0** or newer. Check the installed versions: ```bash -python -m pip install "git+https://github.com/OPM/taskmaestro.git@main" +python -m pip show taskmaestro taskmaestro-resinsight | grep -E "^(Name|Version)" +``` + +If `taskmaestro` is older, upgrade it from Git: + +```bash +python -m pip install --upgrade "git+https://github.com/OPM/taskmaestro.git@main" ``` ## 2. Register the workflows -ResInsight discovers workflows in `~/.taskmaestro/workflows`: +Workflows that installed packages register, such as +`resinsight.completions`, appear automatically in the **Installed** folder +under **Workflows**. They are read-only, but they can be run and duplicated. + +ResInsight also discovers workflow folders in `~/.taskmaestro/workflows`. To +use the example folders from `taskmaestro-resinsight` directly, link them in: ```bash mkdir -p ~/.taskmaestro/workflows @@ -55,6 +64,8 @@ for workflow in ~/resinsight-workflows/taskmaestro-resinsight/workflows/*/; do done ``` +Workflows you create in ResInsight are saved to this folder by default. + ## 3. Configure ResInsight Open **Edit → Preferences** and set: @@ -69,18 +80,81 @@ Open **Edit → Preferences** and set: 3. **System → Experimental Features → Workflows** to on. Restart ResInsight. The **Workflows** node should now appear in the project -tree. Select a workflow, provide its inputs, and click **Run**. +tree. Select a workflow job, provide its inputs, and click **Run**. + +## 4. Create and edit workflows + +Selecting a workflow opens the workflow editor in a tab next to the 3D views. +It shows the workflow graph, a palette of all installed tasks on the right, +and a status line with validation results. Right-click the graph background +for Undo, Redo, Save, Save As, and Duplicate as Editable. + +- **New workflow:** right-click **Workflows** and choose **New Workflow**. +- **Copy a workflow:** choose **Duplicate as Editable** on any workflow, + including installed ones. +- **Add a task:** drag it from the palette onto the graph, double-click it in + the palette, or right-click the graph and use **Add Task**. Tasks that need + a ResInsight connection are connected to a *connect* task automatically. +- **Connect tasks:** drag from an output port (right side of a task) to an + input port (left side). While dragging, valid targets are green; invalid + ones are grey and show the reason as a tooltip. +- **Config inputs:** every required input that is not connected becomes a + config input, shown in the property view when the task is selected. + Optional inputs can be turned on under **Optional Inputs** in the task's + context menu. +- **Collect outputs:** an input that takes a list or a dict (ring around the + port) accepts several connections. Each connection is labelled with its + position or key. Right-click a connection to **Rename Key** or **Move + Earlier/Later**. +- **Map over items:** right-click a task and choose **Map Over** to run it + once per item of a mapping. Choose the inputs that receive the key and the + value of each item. The items are entered in the property view, either as + JSON or by selecting cases, well paths or views. **Remove Map** undoes it. +- **Rename, Set as Result Task, Delete:** right-click a task or connection. + F2 renames and Del deletes the selection. Ctrl+Z and Ctrl+Y undo and redo. + +The workflow is validated with taskmaestro shortly after each edit. Problems +are shown as badges on the tasks and in the status line. A workflow that +fails validation can still be run after confirming. + +Running a workflow with unsaved edits runs the edited version from a +temporary folder. The files on disk are not changed until you save. + +### Saving + +**Save** (Ctrl+S) writes `workflow.yaml` and `input.yaml` to the workflow +folder. **Save As** saves to a new folder. Note that: + +- The first save keeps the original as `workflow.yaml.bak`. +- The YAML is rewritten. The comment block at the top of `workflow.yaml` is + kept; other comments are lost. +- `input.yaml` gets the values of the workflow's first job (**Default**). + Selected cases, well paths, and views are not saved. +- Folders outside `~/.taskmaestro/workflows` are not found when ResInsight + starts or rescans. + +Use **Rescan Workflows** on the **Workflows** node after changing workflow +folders or installing packages. ResInsight asks before closing or rescanning +when there are unsaved edits. + +Workflows that use nested workflows open read-only, and the status line +explains why. They can still be run. ## Troubleshooting - **No Workflows node:** Enable the experimental feature and restart ResInsight. - **The Workflows node is empty:** Check that each directory under - `~/.taskmaestro/workflows` contains `workflow.yaml`, then restart ResInsight. + `~/.taskmaestro/workflows` contains `workflow.yaml`, then use **Rescan + Workflows**. +- **The task palette is empty or the Installed folder is missing:** The task + catalog failed to load. Hover over *Task catalog not available* in the + palette to see why. Check that + `taskmaestro` and the task packages are installed in the configured Python + environment. - **A Python module cannot be found:** Confirm that **Python Executable Location** points to the virtual environment created above. -- **A workflow fails to load with `invalid choice: 'workflow'`, or its inputs - are not pre-filled from `input.yaml`:** The installed `taskmaestro` is too - old; reinstall it as described above. +- **Workflows fail to load with a message about the taskmaestro version:** + Upgrade `taskmaestro` as described above. - **The workflow cannot connect:** Enable **Python Script Server** and restart ResInsight. diff --git a/docs/workflow-ui-design.md b/docs/workflow-ui-design.md new file mode 100644 index 0000000000..874dfc78e5 --- /dev/null +++ b/docs/workflow-ui-design.md @@ -0,0 +1,415 @@ +# Workflow UI design + +## What this branch adds + +A new **Workflows** node in the project tree, next to **Scripts**. It lists +[taskmaestro](https://github.com/OPM/taskmaestro) workflows found on disk and +the workflows that installed packages register. For each one it +auto-generates a property editor for the config inputs, and it runs the +workflow as a subprocess that talks back to ResInsight over the existing gRPC +channel. + +Workflows can also be **created and edited** in ResInsight. A graph editor +with a task palette builds the workflow from registered tasks and saves it as +a standard `workflow.yaml` + `input.yaml`. + +The task classes are the source of truth. ResInsight reads the Pydantic input +and output models of each task, which are exported as JSON schemas, to build +the property editors and to check which ports can be connected. There is no +manifest file or plugin descriptor. + +## Architecture at a glance + +``` +ResInsight (C++) Python helper (rips.taskmaestro_helper) +───────────────── ─────────────────────────────────────── +RimWorkflowTaskCatalog ────── catalog ─────► entry points → task descriptors + registered workflows +RimWorkflowCollection + folders in ~/.taskmaestro/workflows ─ load ─► workflow.yaml + input.yaml → definition JSON + Installed (RimWorkflowInstalledCollection) (from the catalog) +RimWorkflow + RimWorkflowDefinition (value struct) + edits → applyEdit → undo/redo, dirty + Save ──────────────── save (stdin JSON) ──► definition JSON → workflow.yaml + input.yaml + RiuWorkflowValidationRunner ─ save --describe ► temp folder + taskmaestro describe + RimWorkflowJob (one or more) + RimWorkflowTaskInput (one per task) + RimWorkflowBinding (one per config field, see table below) + Run ─ writes input.yaml ─ QProcess ─────► run | run --registered + ◄───── JSON events → task states in the graph +``` + +The C++ side never parses YAML and doesn't import Pydantic. It only handles +JSON (Qt's `QJsonDocument`); `input.yaml` for a run is written as JSON text, +which YAML accepts. The Python helper does everything that touches YAML or +the upstream library. Every helper call goes through +`RimWorkflowHelperProcess::runSync` (or the async validation runner), which +finds the Python interpreter and decodes the single JSON document on stdout. + +## The Python helper + +`python -m rips.taskmaestro_helper ` +([__main__.py](../GrpcInterface/Python/rips/taskmaestro_helper/__main__.py)). +Every subcommand except `run` prints exactly one JSON document. Output +written while task modules are imported goes to stderr. On failure the +helper exits with code 2 and prints +`{"status": "invalid", "error": {"message", "task", "field"}}`. +taskmaestro 0.4.0 or newer (below 0.5) is required (the `workflow` extra of +`rips`); a missing or older taskmaestro is reported as an import error. + +| Subcommand | Purpose | +|---|---| +| `catalog [--no-workflows]` | All tasks from the `taskmaestro.tasks` entry points, as descriptors (`id`, `name`, `python_type`, `description`, `input_schema`, `output_schema`), and all workflows from `taskmaestro.workflows`. A broken plugin is reported in `errors`; the rest still load. | +| `load [--describe]` | Reads `workflow.yaml` + `input.yaml` into a definition JSON (below). | +| `save --out-dir D [--workflow-only] [--describe] [--backup]` | Reads a definition JSON on stdin and writes the YAML files atomically. `--describe` also runs `taskmaestro workflow describe` on the result, which is how validation works. | +| `export-registered [--describe]` | A registered workflow as a definition JSON. | +| `run ` / `run --registered ` | Runs a workflow (see [The run flow](#the-run-flow)). | + +`schema.py` generates the task schemas. It adds `x-ri-python-type` and +`x-ri-python-bases` (the class MRO) to models and opaque types, so that C++ +can check subclass relations without Python. + +## The definition model + +`RimWorkflowDefinition` +([RimWorkflowDefinition.h](../ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDefinition.h)) +is a plain value struct that mirrors the helper's definition JSON: + +```json +{ "format": 1, "name": "resinsight_completions", "result_task": null, + "editable": true, "readonly_reasons": [], "warnings": [], + "header_comment": "# ...", "passthrough": {"runner": {}}, + "nodes": [{"name": "add_perf_1", "task": "resinsight.add_perforation", + "config_fields": ["event_date", "start_md", "end_md"]}], + "edges": [{"from": "connect_to_resinsight", "output": null, + "to": "add_perf_1", "input": "resinsight"}], + "inputs": {"add_perf_1": {"start_md": 3000.0}}, + "task_types": {"resinsight.add_perforation": {"...": "descriptor"}}, + "describe": {}, "describe_error": null } +``` + +- A `null` `output`/`input` on an edge means the whole model. `[task, field]` + routing and fan-in (`{field: task}`) are both expressed as edges. +- `task_types` embeds the descriptor of every task used, so a workflow can + still be edited when the catalog fails to load. +- `save` always writes `name:` on every entry and refers to tasks by instance + name, because taskmaestro cannot resolve default names in `depends_on`. + Linear (implicit) workflows are converted to explicit edges on load. +- The definition is never written to the `.rsp` file. Undo and redo store + value copies (`RimWorkflow`, capped at 100 steps). + +All edits are pure functions in `RimWorkflowDefinitionTools` that return +`std::expected`: `addTask`, `removeTask`, +`connect`, `disconnect`, `renameTask`, `renameWorkflow`, `setResultTask` +and `setOptionalInputConfigured`. After each edit, `normalize` re-derives the +config fields: every required input field that no edge covers becomes a +config field, as do optional fields the user has turned on. + +`addTask` gives the node a unique instance name. It also auto-wires inputs of +type `RipsInstance` to a `resinsight.connect` node, which is created the first +time it is needed. + +### Read-only workflows + +A workflow opens read-only, showing the reasons in the editor's status line, +when: + +- it is **installed** (registered by a package). Use *Duplicate as Editable* + to get an editable copy. +- it uses features the editor does not support: nested `workflow:` entries, + or task classes that cannot be imported. + +Read-only workflows can still be run. + +## Port typing + +`RimWorkflowPortCompatibility` lists the input and output ports of a task from +its schemas. The whole model is a port with an empty name, and the opaque +`value` field of an `ObjectModel` is hidden. `isCompatible(produced, expected)` +checks the following, in order: + +1. `Any` accepts everything. +2. `anyOf`: every produced option must match; at least one expected option + must match. +3. Opaque types: the expected Python type must be among the produced type's + bases, so `PerforationOutput` does not connect to `WellPath` even though + both wrap `rips.WellPath`. +4. Models: compare `x-ri-python-type` against the produced bases. +5. Arrays: compare the items. +6. Scalars: `type` and `format` must be equal. bool → int is allowed; + int → float is not. Enums must be a subset. + +`canConnect` also rejects self-loops, cycles, and a whole-input edge on a task +that already has field edges (and the reverse). The error text is shown as a +tooltip while dragging. + +## Workflow graph and editor + +Selecting a `RimWorkflow` (or one of its jobs) shows `RiuWorkflowEditorWidget` +in a central dock tab: + +- the graph (`RiuWorkflowGraphView`); +- the task palette (`RiuWorkflowTaskPalette`) to the right of the graph: a + filter field above a tree grouped by id prefix. Drag a task onto the + graph, or double-click it; +- a status line for load errors, read-only reasons, validation results and + unsaved changes. + +Each port shows its name with its type below it in smaller text (from the +task schemas, as `input_types`/`output_types` in the graph JSON). The +tooltip also shows the description. Ports that hold a ResInsight object +(instance, case, view, well path, surface) show that object's project tree +icon in front of the type (`input_icons`/`output_icons`). The rips class is +found in the `x-ri-python-bases`, either directly or as `ObjectModel[Class]` +(`RimWorkflowPortCompatibility::iconResource`). Other ports show an icon for +their kind of value: string, integer, float, bool, date, file path or +directory. +These are Codicons (CC BY 4.0), kept with their licence and source in +`ApplicationExeCode/Resources/codicons`. + +`RiuWorkflowGraphLayout` computes the layout: topological ranks with a Kahn +sort and longest-path ranks, then one barycenter pass to order nodes. It +tolerates cycles. The layout is always automatic; nodes moved by hand keep +their position only for the session. After an edit the viewport is kept. + +In an editable workflow: + +- Drag from an output port to an input port to connect. Valid targets turn + green, invalid ones grey with the reason as a tooltip. Escape cancels. +- Unwired optional inputs are drawn as hollow ports. +- Right-click a task for Rename (F2), Set as Result Task, Optional Inputs ▸ + and Delete (Del). Right-click a connection for Delete Connection. + Right-click the background for Add Task ▸, Undo, Redo, Save, Save As, + Duplicate as Editable and Fit. There is no toolbar. +- Ctrl+Z/Ctrl+Y undo and redo; Ctrl+S saves. + +### Collect and map + +`collect` (taskmaestro 0.3) gathers several outputs into one input. In the +definition each member is a separate edge with `collect: list|dict` (and a +`key` for dicts); the order of the list edges is the list order. +`RimWorkflowPortCompatibility::resolveConnection` decides whether a drop is +a plain edge or a collect member: an input typed `list[T]` or +`dict[str, T]` collects outputs of type `T`, while an output of the whole +container type still connects directly. Collecting inputs are drawn with a +ring around the port; member edges are labelled `[i]` or with their key. +Dict keys default to the source task name and are made unique. Right-click +a member for Rename Key… (dict) or Move Earlier/Later (list). + +`map` runs a task once per item of a configured mapping. Right-click a task +for Map Over… (Edit Map… when mapped) and Remove Map. The dialog picks the +mapping's config name (`over`), the inputs that receive the key (`key_as`, +must accept a string) and the value (`value_as`), and the error mode +(`fail_fast` or `collect_all`); `checkTaskMap` validates it while typing. +`taskTypeForNode` gives the effective task type of a mapped node: `key_as` +and `value_as` are no longer inputs, and the output becomes +`dict[str, Output]`. `graphTask` adds `over` as a config field and port +(`map_over: true`) typed `dict[str, ]` (`mapOverSchema`). +The node is drawn as a stack with a "⟳ map over X" subtitle. The `over` value is edited with `RimWorkflowMappingBinding`: a JSON +object, or a multi-selection of cases, well paths or views keyed by name. +During a run the helper emits `map_item_state` events; the node shows +"done/seen" counts and failures, and the tooltip lists each item. + +Selecting a task shows its `RimWorkflowTaskInput` from the displayed job in +the property view. Editing is locked while a job of the workflow is running. +During a run, task states are drawn on the nodes. + +## Validation + +Two layers of validation run: + +- **Static**, in C++ (`RimWorkflowDefinitionTools::validate`): unknown tasks, + invalid names, required opaque inputs that are not wired, multiple sinks + without a result task, and so on. The results are shown as badges on the + nodes right away. +- **Live**, through taskmaestro: `RiuWorkflowValidationRunner` waits 400 ms + after the last edit, then runs `save --out-dir --describe` in the + background. A new edit kills a stale run, results for an old revision are + dropped, and a 30 s watchdog stops hung processes. + `RimWorkflowValidationTools::issuesFromHelperResult` maps the errors to + tasks; anything left over goes to the status line. +- **Missing values** come from the displayed job, not from describe. The saved + definition only carries literal values, so describe would report cases, well + paths and views as missing. The editor replaces describe's "has no value" + warnings with ones computed from the job's bindings + (`RimWorkflowValidationTools::issuesWithJobValues`). They update as soon as + a value is edited. + +Running a workflow whose last validation failed asks for confirmation first. + +## Saving + +- **Save** writes `workflow.yaml` and `input.yaml` in the workflow folder. + The first save makes `workflow.yaml.bak`. Before overwriting, ResInsight + warns if the file changed on disk since it was loaded, or if the folder is + a symlink into a git checkout. +- **Save As** and **New Workflow** ask for a name and a folder. The default + folder is `~/.taskmaestro/workflows/`; a folder elsewhere is not + discovered on the next rescan, and ResInsight warns about that. `*.py` files + next to the original `workflow.yaml` are copied along, so tasks given as a + dotted path (`pipeline.MyTask`) still import. +- `input.yaml` gets the literal values of the **first job** (the "Default" + job). Object references (cases, well paths, views) are not saved. +- YAML comments other than the leading header block are lost, and the YAML + is reformatted. + +Closing ResInsight or the project, and **Rescan Workflows**, ask before +discarding unsaved edits. + +## Type / format → binding mapping + +`createBinding()` dispatches in this order — first `resinsight_type` (rips +object pickers take priority), then `format`, then bare `type`. Everything +unrecognised falls through to the string binding. + +| Field annotation (Pydantic, in the workflow's task) | Helper emits | C++ binding | PdmField type | Renders as | +|------------------------------------------------------------------|---------------------------------------|------------------------------|-------------------------------------|------------------------------------------| +| `rips.EclipseCase` / `ObjectModel[rips.EclipseCase]` | `resinsight_type: EclipseCase` | `RimWorkflowCaseBinding` | `PdmPtrField` | Combo box of loaded cases | +| `rips.WellPath` / `ObjectModel[rips.WellPath]` | `resinsight_type: WellPath` | `RimWorkflowWellPathBinding` | `PdmPtrField` | Combo box of well paths | +| `rips.View` / `rips.EclipseView` / wrapper | `resinsight_type: View` | `RimWorkflowViewBinding` | `PdmPtrField` | Combo box of `Case / View` labels | +| `datetime.date` / `datetime.datetime` | `type: string`, `format: date`* | `RimWorkflowDateBinding` | `PdmField` | Calendar widget (`PdmUiDateEditor`) | +| `pathlib.Path` / `pydantic.FilePath` / `pydantic.NewPath` | `type: string`, `format: path` / `file-path` | `RimWorkflowFilePathBinding` | `PdmField` (file mode) | Text field + `[Browse…]` (file dialog) | +| `pydantic.DirectoryPath` | `type: string`, `format: directory-path` | `RimWorkflowFilePathBinding` | `PdmField` (dir mode) | Text field + `[Browse…]` (folder dialog) | +| `bool` | `type: boolean` | `RimWorkflowBoolBinding` | `PdmField` | Checkbox | +| `int` | `type: integer` | `RimWorkflowIntBinding` | `PdmField` | Spinbox | +| `float` | `type: number` | `RimWorkflowFloatBinding` | `PdmField` | Decimal text field | +| `str` (or anything unrecognised) | `type: string` | `RimWorkflowStringBinding` | `PdmField` | Plain text field | + +\*`format` extraction goes through `pydantic.TypeAdapter(field).json_schema()` +so any future Pydantic-native format Pydantic supports is picked up +automatically — no hand-maintained map. The helper reassembles `Annotated` +forms before asking, otherwise `Annotated[Path, PathType('dir')]` would lose +its `dir` marker. + +`RimWorkflowFilePathBinding` is one class with an `m_selectDirectory` bool +toggled at `defineEditorAttribute` time; the same `caf::PdmField` +flips between file-open and folder-pick dialogs based on the schema's format. + +## Pre-filling from `input.yaml` + +A binding's initial value comes from, in order: + +1. the task's block in `input.yaml` (the definition's `inputs`), or +2. the field's `default=` in the Pydantic model, or +3. the `PdmField` default (empty string, 0.0, today's date, and so on). + +Bindings are kept with their values when the workflow is edited, as long as +the field still has the same kind of binding (`syncTaskInputs`). Values of +fields that become wired are kept aside and restored if the field is +unwired again. Renaming a task carries its values along. + +## The rips object reference round-trip + +For fields typed as rips classes, the config field schema carries +`resinsight_type` but no `default` (the C++ picker provides the value +interactively). When the user picks a case in the UI and runs the job, the +C++ binding's `toJsonValue()` writes: + +```yaml +{"load_model": {"case": {"__resinsight_ref__": "EclipseCase", "case_id": 0}}} +``` + +The reverse side, `rips.taskmaestro_helper.run.resolve_refs` +([run.py](../GrpcInterface/Python/rips/taskmaestro_helper/run.py)), walks +the YAML dict and replaces every `{__resinsight_ref__: , …id…}` map +with `{"value": }` before handing it to +`taskmaestro.JobConfiguration`. The `"value": …` shape is exactly what +Pydantic's `ObjectModel[T]` validator accepts, so workflow authors who +declare `case: GridCase` get a real `rips.EclipseCase` injected without +any custom validator code. + +ID schemes: + +| `resinsight_type` | Key in the YAML map | Resolves via | +|-------------------|----------------------|----------------------------------------------| +| `EclipseCase` | `case_id` | `rips.Project.case(case_id=…)` | +| `WellPath` | `well_path_name` | `rips.Project.well_path_by_name(…)` | +| `View` | `view_id` | scan `rips.Project.views()` by `.id` | + +Whitelist lives in +[refs.py](../GrpcInterface/Python/rips/taskmaestro_helper/refs.py) — the +single source of truth for the rips types the helper recognises and +resolves. Adding a new rips-typed +picker means: extend the whitelist, add a `RimWorkflowBinding` class, +register it in `createBinding()`. + +## The run flow + +1. The user runs a `RimWorkflowJob` (*Run* in the context menu or the + property page). +2. `RimWorkflowJob::runJob()` checks that the gRPC port is live, finds a + Python interpreter (`RiaPreferences::pythonExecutable()` → `python3` → + `python`), and writes the job's values to `input.yaml` in a temp folder. +3. `RimWorkflow::prepareRunSource()` picks what to run: + + | Workflow | Command | + |---|---| + | Installed | `run --registered ` | + | Saved, no unsaved edits | `run ` | + | Unsaved edits | `run ` with `PYTHONPATH=` | + + The edit folder is a private temp folder holding the current revision + (`save --workflow-only`). The files on disk are not touched until Save. +4. `RiuWorkflowJobRunner` starts the helper with + `--input /input.yaml --grpc-port --run-id `. The helper + connects to ResInsight, resolves refs, and runs the workflow through + `taskmaestro.Runner`. +5. The helper emits newline-delimited JSON events. Task state events update + the job and the colours of the graph nodes; everything else goes to the + log. Cancel terminates the process. + +## Code layout + +- C++ model: `ApplicationLibCode/ProjectDataModel/Workflow/` (`RimWorkflow*`). +- Commands: `ApplicationLibCode/Commands/WorkflowCommands/`. +- UI: `ApplicationLibCode/UserInterface/RiuWorkflow*`. +- Python helper: `GrpcInterface/Python/rips/taskmaestro_helper/`, tests in + `GrpcInterface/Python/rips/tests/test_taskmaestro_helper*.py` (run with + `--noconftest`). +- C++ unit tests: `ApplicationLibCode/UnitTests/RimWorkflow*-Test.cpp`, + `RiuWorkflowGraphLayout-Test.cpp`. + +Touched (existing files): + +- `ApplicationLibCode/ProjectDataModel/RimProject.{h,cpp}`: the + `workflowCollection` child field, attached at the `MainWindow.Scripts` tab + and marked with `disableIO()`. +- `ApplicationLibCode/UserInterface/RiuMainWindow.{h,cpp}`: hosts the editor + dock and connects graph selection to the property view. +- `ApplicationLibCode/Application/RiaGuiApplication.{h,cpp}`: asks before + unsaved workflow edits are discarded. + +## Constraints + +- **gRPC server must be on.** The run command refuses to launch otherwise. + Set in *Preferences → Python → Enable gRPC server*. +- **`PATH` must point at the Python that has `rips` and `taskmaestro` + installed**, because `findPythonExecutable()` searches `PATH`. Either + launch ResInsight from a shell with the venv on `PATH`, or set + *Preferences → Python → Python Executable Location* to an absolute path. +- **Rescan to pick up changes on disk.** Use *Rescan Workflows* on the + Workflows node after adding folders to `~/.taskmaestro/workflows/` or + installing packages. The task catalog is cached until then. +- **The collection is not serialised** to `.rsp`. Jobs and their values + live for one ResInsight session; save the workflow to keep the Default + job's values in `input.yaml`. + +## Where to extend + +- **New rips-typed picker** (e.g. `rips.SummaryCase`): add the class to + `RIPS_TYPE_WHITELIST` in `refs.py`, add the resolver branch to + `make_rips_resolver` in `run.py`, add a `RimWorkflowSummaryCaseBinding` + modelled on `RimWorkflowCaseBinding`, register it in `createBinding()`. +- **New scalar/format**: usually nothing on the Python side (TypeAdapter + handles it). On the C++ side, add a binding subclass and a one-line + dispatch case. +- **Change the discovery directory**: edit + `RimWorkflowCollection::discoveryDirectory()`. A `RiaPreferences` field + + Preferences UI would be a small follow-up if multiple roots are wanted. +- **New edit operation**: add a pure function to `RimWorkflowDefinitionTools` + with a unit test, call it through `RiuWorkflowEditorWidget::applyEdit`, and + add it to the graph context menu in `RiuWorkflowEditorWidget`. +- **Supporting nested workflows in the editor**: extend the + definition JSON and `RimWorkflowDefinitionNode`, drop the read-only reason + in `definition.py` and `structuralReadOnlyReasons`, and teach `save` to + write them.