Store workflow output - #14062
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## log-workflow-output #14062 +/- ##
=======================================================
+ Coverage 91.77% 91.79% +0.01%
=======================================================
Files 486 486
Lines 34849 34902 +53
=======================================================
+ Hits 31983 32038 +55
+ Misses 2866 2864 -2
Flags with carried forward coverage won't be shown. Click here to find out more.
|
There was a problem hiding this comment.
Pull request overview
This PR adds structured capture and persistence of workflow-job output (stdout/stderr) so that workflows hooked into experiment runs can be logged to <UPDATE_LOG_PATH>/<run_id>/workflows.log and appended to the experiment’s storage directory workflows.log. It introduces a dedicated status event for workflow output, and wires both CLI and GUI monitors to write these logs.
Changes:
- Capture stdout/stderr for internal and external workflow jobs (including failure cases) and surface per-invocation results.
- Introduce
RunModelWorkflowLogEventwith a stable log-entry format, write it to update-log output paths, and append it to experiment storage. - Add unit/UI tests and docs describing the new workflow logging behavior.
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
src/ert/config/ert_script.py |
Adds thread-aware stdout/stderr capture proxy, plus ExternalScriptError handling to avoid ERT-internal stack traces for external job exit failures. |
src/ert/config/external_ert_script.py |
Writes subprocess stdout/stderr to current streams (so ErtScript capture can record it) and raises ExternalScriptError with an exit-code message. |
src/ert/workflow_runner.py |
Adds WorkflowJobResult and WorkflowRunner.jobResults() to preserve per-invocation output (including repeated job runs). |
src/ert/run_models/event.py |
Introduces RunModelWorkflowLogEvent with as_log_entry() and write_as_log() for update-log persistence. |
src/ert/run_models/run_model.py |
Emits workflow log events per job, buffers log entries until an experiment exists, and appends them to experiment storage. |
src/ert/storage/local_experiment.py |
Adds workflow_log_path and append_workflow_log() to persist workflow log entries alongside the experiment. |
src/ert/cli/monitor.py |
Writes workflow log events to <UPDATE_LOG_PATH>/<run_id>/workflows.log during CLI runs. |
src/ert/gui/experiments/run_dialog.py |
Writes workflow log events to the GUI output path. |
tests/ert/unit_tests/workflow_runner/test_workflow_runner.py |
Adds coverage for external-job stdout capture, exit-code error reporting, and per-invocation job results. |
tests/ert/unit_tests/workflow_runner/test_ert_script.py |
Adds coverage for stdout/stderr capture, failure behavior, and concurrent/threaded capture isolation. |
tests/ert/unit_tests/run_models/test_workflow_log_event.py |
New tests for RunModelWorkflowLogEvent log formatting and file append semantics. |
tests/ert/unit_tests/run_models/test_status_events_serialization.py |
Extends status-event serialization coverage to include RunModelWorkflowLogEvent. |
tests/ert/unit_tests/run_models/test_base_run_model.py |
Adds tests asserting workflow log event emission and storage persistence behavior across hooks/iterations. |
tests/ert/unit_tests/gui/experiments/test_run_dialog.py |
Adds GUI test to verify workflow log events are written to the output path. |
tests/ert/ui_tests/cli/test_cli.py |
Adds CLI UI test ensuring workflow output is written into the update log path. |
docs/ert/reference/workflows/complete_workflows.rst |
Documents that hooked workflow output is also written to update-log and experiment storage logs. |
docs/ert/reference/configuration/keywords.rst |
Documents the workflow log location/format under UPDATE_LOG_PATH, including storage-copy behavior and hook scope. |
Suppressed comments (1)
tests/ert/unit_tests/workflow_runner/test_ert_script.py:183
- Thread exceptions can be missed, and the join loop doesn’t assert that worker threads actually finished. Add an is_alive() assertion after each join so this test fails deterministically instead of leaking threads on failure paths.
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=10)
69e54f9 to
53b84d8
Compare
11a454c to
74d8bfe
Compare
03e0e55 to
598ec95
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 18 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/ert/config/ert_script.py:63
- The linked issue requires workflow stdout/stderr to be rerouted away from the user's terminal, but this forwarding path deliberately writes every captured byte back to the original stream. As a result, internal and external workflow output still appears in the terminal in addition to the log, so #13320's requested behavior is not implemented. Suppress forwarding for a thread with an active capture while continuing to forward writes from unrelated threads, and update the passthrough test accordingly.
if self.wrapped is None:
return len(s)
return self.wrapped.write(s)
src/ert/run_models/run_model.py:947
- This clears all pending events even when
append_workflow_events()raises. A transient storage error therefore permanently drops buffered PRE_EXPERIMENT and current-hook output, and later hooks cannot retry it. Clear only events known to have been committed; because the append can partially succeed, this needs an atomic append or a commit result rather than simply retaining or clearing the whole batch.
except Exception:
logger.exception("Failed to persist workflow events to storage")
self._pending_workflow_events = []
docs/ert/reference/workflows/complete_workflows.rst:116
- This documents a GUI tab that this PR does not add: there is no workflow-event reader or “Workflow events” tab under
src/ert/gui, and the PR description explicitly defers frontend consumption to a later PR. Users following these instructions cannot view the stored output as described; document the file as reserved for the future GUI instead.
in the GUI, select the experiment in the *Experiments* tool, then select the
*Workflow events* tab.
The GUI will show a list of all workflow jobs that have
been run for that experiment, and clicking on a job will show its output.
597ce7a to
a5b9131
Compare
e72d575 to
5aeeefe
Compare
5aeeefe to
c95c925
Compare
Internal python workflow jobs wrote straight to sys.stdout/sys.stderr, so their output was visible in the terminal but not available to ERT itself. External jobs captured their subprocess output but did not pass stdout on to the terminal. Replace sys.stdout/sys.stderr with a proxy while a job runs, recording what the calling thread writes while still forwarding it to the real stream. Capture is per-thread, so concurrently running jobs do not pick up each other's output. Also stop reporting a failing external job with an ERT stack trace: the new ExternalScriptError carries the exit code, since the traceback would only show ERT internals. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
WorkflowJobRunner.run() set the running flag before validating its arguments but only cleared it on the success path, so a job rejected for too few or too many arguments kept reporting that it was still running. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
workflowReport() is keyed by job name, so a workflow running the same job more than once kept only the output of the last invocation. Add WorkflowJobResult, recording name, index, arguments, output, status and timestamp per invocation, and expose the ordered list through workflowJobResults(). workflowReport() is left as it is, since the run workflow tool and the CLI still use it. Jobs skipped because the workflow was cancelled are recorded too, so a cancelled workflow can be told apart from one that never ran. A job that stops the workflow is now recorded before the error is raised, so its output is not lost. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Workflow carried no name of its own, so anything reporting on a workflow had to fall back to the source file path. LOAD_WORKFLOW lets a workflow be given a name, and that is the name users recognise. Store the name on Workflow, defaulting to the file name when none is given, so it can be reported alongside the jobs that were run. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Workflow job output only reached the terminal ERT was started from, so it was lost once that terminal was gone, and a job run outside a terminal left no record at all. Write one entry per job invocation to the ERT log, naming the hook, the workflow and the job, and quoting the arguments, stdout and stderr. This covers hooked workflows, 'ert workflow' and the GUI Run workflow tool, since all three go through WorkflowRunner. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace the failed/cancelled boolean pair on WorkflowJobResult with a single WorkflowJobStatus enum. The two booleans could express states that do not exist, and a job interrupted mid-run was reported as failed because only the pre-start cancellation path set cancelled=True. Such a job is now reported as cancelled. Also rename workflowJobResults to workflow_job_results and fix the heading underline length in the workflow documentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
_CaptureProxy overrode six members that io.IOBase already provides identically. encoding, errors and newlines are kept because TextIOBase defines them as descriptors returning None, so __getattr__ never sees them, and writable is kept because IOBase defaults it to False. The starting and result log lines both began with "Workflow job" but meant different things. They now share one description and always state the hook, using hook=None for runs outside a hook. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The stream proxy and its context manager are self-contained and have nothing to do with ErtScript beyond the single call that uses them, so they crowded out the module they lived in. The new module opens by stating why contextlib.redirect_stdout is not used, since that is the first thing a reader is likely to wonder. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The log line format is a concern of the runner, not of the result it records: WorkflowJobResult is also persisted, and the starting line has to be written before any result exists. Move the formatting onto WorkflowRunner so both lines are built in one place and the dataclass stays pure data. Also drop articles from the test names added here and describe LOAD_WORKFLOW's second argument as a name, matching the documentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Workflow job output is written to the ERT log, but the GUI has no way to show it, since it never sees the log. Add WorkflowEvent carrying the output of a single job invocation, so it can travel over the status queue like the other run model events. It is a plain BaseModel rather than a RunModelEvent because PRE_EXPERIMENT hooks run before an ensemble exists, so the mandatory iteration and run_id of RunModelEvent are not always available. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Workflow output lives only in the ERT log, which the GUI cannot read and which is not tied to the experiment the workflow ran for. Record the events alongside the experiment, one JSON object per line in workflow_events.jsonl, so the output of a hooked workflow can be found again from the experiment it belongs to. Appending keeps every hook of a run in one file, in the order they ran. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Hooked workflows produce output that is only in the ERT log, so the GUI cannot show what a workflow did, neither while it runs nor afterwards. Emit a WorkflowEvent per job invocation from run_workflows and persist them to the experiment. Workflows skipped because the user cancelled are reported as cancelled rather than dropped, so the run is not misrepresented as having completed. Events are held back until an experiment exists, since PRE_EXPERIMENT hooks run before storage is created, and are flushed in a finally block so output survives a workflow that stops the experiment. Failing to persist is logged but does not abort the experiment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
c95c925 to
dedec36
Compare
Issue
Resolves #13320
Approach
Second of two PRs splitting the original "Store workflow output" work. The base
PR (#14301) captures workflow job
output and writes it to the ERT log; this one makes that output available to the
GUI and ties it to the experiment it belongs to.
WorkflowEvent, carrying the output of a single job invocation so it cantravel over the status queue like the other run model events. It is a plain
BaseModelrather than aRunModelEventbecausePRE_EXPERIMENThooks runbefore an ensemble exists, so the mandatory
iterationandrun_idofRunModelEventare not always available.workflow_events.jsonl. Appending keeps every hook of a run in one file, inthe order they ran.
run_workflows. Workflows skippedbecause the user cancelled are reported as cancelled rather than dropped, so
the run is not misrepresented as having completed.
Events are held back until an experiment exists, since
PRE_EXPERIMENThooks runbefore storage is created, and are flushed in a
finallyblock so outputsurvives a workflow that stops the experiment. Failing to persist is logged but
does not abort the experiment.
Events are consumed by the GUI in the follow-up PRs in this stack.
git rebase -i main --exec 'just rapid-tests')When applicable
merge screenshot-PR in ert-testdata before merging this PR.