Undo, Redo, Save, Save As, Duplicate as Editable and Fit are now in the graph background context menu; keyboard shortcuts are unchanged. The task palette is placed to the right of the graph.
23 KiB
Workflow UI design
What this branch adds
A new Workflows node in the project tree, next to Scripts. It lists 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)
RimWorkflow<Type>Binding (one per config field, see table below)
Run ─ writes input.yaml ─ QProcess ─────► run <dir> | run --registered <id>
◄───── 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 <subcommand>
(__main__.py).
Every subcommand except run prints exactly one JSON document. Output
written while task modules are imported goes to stderr. On failure the
helper exits with code 2 and prints
{"status": "invalid", "error": {"message", "task", "field"}}.
taskmaestro 0.3.0 or newer is required (_compat.py).
| Subcommand | Purpose |
|---|---|
catalog [--no-workflows] |
All tasks from the taskmaestro.tasks entry points, as descriptors (id, name, python_type, description, input_schema, output_schema), and all workflows from taskmaestro.workflows. A broken plugin is reported in errors; the rest still load. |
load <dir> [--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 <id> [--describe] |
A registered workflow as a definition JSON. |
run <dir> / run --registered <id> |
Runs a workflow (see 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)
is a plain value struct that mirrors the helper's definition 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
nulloutput/inputon an edge means the whole model.[task, field]routing and fan-in ({field: task}) are both expressed as edges. task_typesembeds the descriptor of every task used, so a workflow can still be edited when the catalog fails to load.savealways writesname:on every entry and refers to tasks by instance name, because taskmaestro cannot resolve default names independs_on. Linear (implicit) workflows are converted to explicit edges on load.- The definition is never written to the
.rspfile. Undo and redo store value copies (RimWorkflow, capped at 100 steps).
All edits are pure functions in RimWorkflowDefinitionTools that return
std::expected<RimWorkflowDefinition, QString>: addTask, removeTask,
connect, disconnect, renameTask, renameWorkflow, setResultTask
and setOptionalInputConfigured. After each edit, normalize re-derives the
config fields: every required input field that no edge covers becomes a
config field, as do optional fields the user has turned on.
addTask gives the node a unique instance name. It also auto-wires inputs of
type RipsInstance to a resinsight.connect node, which is created the first
time it is needed.
Read-only workflows
A workflow opens read-only, showing the reasons in the editor's status line, when:
- it is installed (registered by a package). Use Duplicate as Editable to get an editable copy.
- it uses features the editor does not support:
collect,map, nestedworkflow: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:
Anyaccepts everything.anyOf: every produced option must match; at least one expected option must match.- Opaque types: the expected Python type must be among the produced type's
bases, so
PerforationOutputdoes not connect toWellPatheven though both wraprips.WellPath. - Models: compare
x-ri-python-typeagainst the produced bases. - Arrays: compare the items.
- Scalars:
typeandformatmust 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.
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.
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:
RiuWorkflowValidationRunnerwaits 400 ms after the last edit, then runssave --out-dir <tmp> --describein 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::issuesFromHelperResultmaps the errors to tasks; anything left over goes to the status line.
Running a workflow whose last validation failed asks for confirmation first.
Saving
- Save writes
workflow.yamlandinput.yamlin the workflow folder. The first save makesworkflow.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/<name>; a folder elsewhere is not discovered on the next rescan, and ResInsight warns about that.*.pyfiles next to the originalworkflow.yamlare copied along, so tasks given as a dotted path (pipeline.MyTask) still import. input.yamlgets 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<RimEclipseCase*> |
Combo box of loaded cases |
rips.WellPath / ObjectModel[rips.WellPath] |
resinsight_type: WellPath |
RimWorkflowWellPathBinding |
PdmPtrField<RimWellPath*> |
Combo box of well paths |
rips.View / rips.EclipseView / wrapper |
resinsight_type: View |
RimWorkflowViewBinding |
PdmPtrField<RimEclipseView*> |
Combo box of Case / View labels |
datetime.date / datetime.datetime |
type: string, format: date* |
RimWorkflowDateBinding |
PdmField<QDate> |
Calendar widget (PdmUiDateEditor) |
pathlib.Path / pydantic.FilePath / pydantic.NewPath |
type: string, format: path / file-path |
RimWorkflowFilePathBinding |
PdmField<caf::FilePath> (file mode) |
Text field + [Browse…] (file dialog) |
pydantic.DirectoryPath |
type: string, format: directory-path |
RimWorkflowFilePathBinding |
PdmField<caf::FilePath> (dir mode) |
Text field + [Browse…] (folder dialog) |
bool |
type: boolean |
RimWorkflowBoolBinding |
PdmField<bool> |
Checkbox |
int |
type: integer |
RimWorkflowIntBinding |
PdmField<int> |
Spinbox |
float |
type: number |
RimWorkflowFloatBinding |
PdmField<double> |
Decimal text field |
str (or anything unrecognised) |
type: string |
RimWorkflowStringBinding |
PdmField<QString> |
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<caf::FilePath>
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:
- the task's block in
input.yaml(the definition'sinputs), or - the field's
default=in the Pydantic model, or - the
PdmFielddefault (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:
{"load_model": {"case": {"__resinsight_ref__": "EclipseCase", "case_id": 0}}}
The reverse side, rips.taskmaestro_helper.run.resolve_refs
(run.py), walks
the YAML dict and replaces every {__resinsight_ref__: <type>, …id…} map
with {"value": <live rips object>} 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 — 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 RimWorkflow<X>Binding class,
register it in createBinding().
The run flow
-
The user runs a
RimWorkflowJob(Run in the context menu or the property page). -
RimWorkflowJob::runJob()checks that the gRPC port is live, finds a Python interpreter (RiaPreferences::pythonExecutable()→python3→python), and writes the job's values toinput.yamlin a temp folder. -
RimWorkflow::prepareRunSource()picks what to run:Workflow Command Installed run --registered <id>Saved, no unsaved edits run <workflow folder>Unsaved edits run <edit folder>withPYTHONPATH=<original folder>The edit folder is a private temp folder holding the current revision (
save --workflow-only). The files on disk are not touched until Save. -
RiuWorkflowJobRunnerstarts the helper with--input <tmp>/input.yaml --grpc-port <N> --run-id <id>. The helper connects to ResInsight, resolves refs, and runs the workflow throughtaskmaestro.Runner. -
The helper emits newline-delimited JSON events. Task state events update the job and the colours of the graph nodes; everything else goes to the log. Cancel terminates the process.
File layout (new)
ApplicationLibCode/ProjectDataModel/Workflow/
├── RimWorkflow.{h,cpp} # one workflow: definition, undo, save, run source
├── RimWorkflowCollection.{h,cpp} # the project-tree collection, rescan
├── RimWorkflowInstalledCollection.{h,cpp} # the "Installed" folder
├── RimWorkflowJob.{h,cpp} # one set of input values; runs the workflow
├── RimWorkflowTaskInput.{h,cpp} # one entry per task
├── RimWorkflowDefinition.{h,cpp} # value structs: nodes, edges, issues
├── RimWorkflowDefinitionTools.{h,cpp} # JSON conversion, edit operations, static validation
├── RimWorkflowPortCompatibility.{h,cpp} # ports and type checks
├── RimWorkflowSchemaTools.{h,cpp} # JSON schema helpers
├── RimWorkflowTaskCatalog.{h,cpp} # cached `catalog` output
├── RimWorkflowHelperProcess.{h,cpp} # runs the Python helper
├── RimWorkflowValidationTools.{h,cpp} # helper result → issues
├── RimWorkflowDescribeTools.{h,cpp} # graph from `taskmaestro describe` (read-only workflows)
├── RimWorkflowFieldBinding.{h,cpp} # abstract binding base
└── RimWorkflow<Type>Binding.{h,cpp} # Bool, Int, Float, String, Date, FilePath,
# Array, Case, WellPath, View
ApplicationLibCode/Commands/WorkflowCommands/
├── RicNewWorkflowFeature, RicDuplicateWorkflowAsEditableFeature,
│ RicSaveWorkflowFeature, RicSaveWorkflowAsFeature, RicRescanWorkflowsFeature
├── RicAddWorkflowTaskFeature, RicDeleteWorkflowTaskFeature,
│ RicDeleteWorkflowConnectionFeature, RicRenameWorkflowTaskFeature,
│ RicSetWorkflowResultTaskFeature, RicToggleWorkflowOptionalInputFeature
├── RicNewWorkflowJobFeature, RicRunWorkflowJobFeature, RicCancelWorkflowJobFeature
├── RicWorkflowFeatureTools.{h,cpp} # shared helpers for the features
└── RicWorkflowLocationUi.{h,cpp} # name + folder dialog
ApplicationLibCode/UserInterface/
├── RiuWorkflowEditorWidget.{h,cpp} # the dock: graph, palette, status
├── RiuWorkflowGraphView.{h,cpp} # QGraphicsView with editing
├── RiuWorkflowGraphLayout.{h,cpp} # pure layout (QtCore only)
├── RiuWorkflowTaskPalette.{h,cpp}
├── RiuWorkflowValidationRunner.{h,cpp} # debounced async validation
└── RiuWorkflowJobRunner.{h,cpp} # runs a job, parses events
GrpcInterface/Python/rips/taskmaestro_helper/
├── __main__.py # CLI dispatch
├── _compat.py # taskmaestro version check, HelperError
├── catalog.py # catalog, export-registered
├── definition.py # load, save
├── describe.py # taskmaestro describe, in process
├── schema.py # task schemas and descriptors
├── refs.py # rips type whitelist + REF_MARKER
└── run.py # resolve refs + invoke Runner
GrpcInterface/Python/rips/tests/
├── test_taskmaestro_helper.py
├── test_taskmaestro_helper_definition.py # run with --noconftest
└── test_data/taskmaestro_workflows/
ApplicationLibCode/UnitTests/
├── RimWorkflowDefinition-Test.cpp # fixtures in TestData/RimWorkflowDefinition/
├── RimWorkflowJobSync-Test.cpp
├── RimWorkflowDescribeTools-Test.cpp
└── RiuWorkflowGraphLayout-Test.cpp
Touched (existing files):
ApplicationLibCode/ProjectDataModel/RimProject.{h,cpp}: theworkflowCollectionchild field, attached at theMainWindow.Scriptstab and marked withdisableIO().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.
PATHmust point at the Python that hasripsandtaskmaestroinstalled, becausefindPythonExecutable()searchesPATH. Either launch ResInsight from a shell with the venv onPATH, 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 ininput.yaml.
Where to extend
- New rips-typed picker (e.g.
rips.SummaryCase): add the class toRIPS_TYPE_WHITELISTinrefs.py, add the resolver branch tomake_rips_resolverinrun.py, add aRimWorkflowSummaryCaseBindingmodelled onRimWorkflowCaseBinding, register it increateBinding(). - 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(). ARiaPreferencesfield- Preferences UI would be a small follow-up if multiple roots are wanted.
- New edit operation: add a pure function to
RimWorkflowDefinitionToolswith a unit test, call it throughRimWorkflow::applyEdit, and expose it with aRic*WorkflowFeaturethat reads its arguments fromuserData(). - Supporting
collect/map/nested workflows in the editor: extend the definition JSON andRimWorkflowDefinitionNode, drop the read-only reason indefinition.pyandstructuralReadOnlyReasons, and teachsaveto write them.