Files
bahdotsh 31cf534625 feat(tui): live step view with streamed stdout/stderr
The execution tab used to stare back at you with a single progress
bar while the executor did... whatever it was doing. You found out
what happened when the whole workflow was over, and only then. Not
great for a tool whose entire point is running workflows locally.

The reason was structural. `execute_workflow` handed back one
`Vec<JobResult>` at the very end, `StepStatus` only had terminal
variants (Success/Failure/Skipped — no Running, no Pending), and
the container runtimes read logs *after* the container exited via
`wait_container` followed by `logs().collect()`. The TUI was doing
the best it could with a request/response it couldn't observe.

So: thread an optional event stream through the executor. New
`ExecutionEvent` enum (`JobStarted`, `StepStarted`, `StepLogChunk`,
`StepCompleted`, `JobCompleted`, plus `Execution*` bookends) emitted
via an `EventSink` that bundles an unbounded sender with a per-run
`JobId` allocator so matrix combinations get distinct ids. The TUI
spawns a receiver, drains it each tick, and paints the detail view
live — spinner on the running step, ✓/✖/⊘/○ everywhere else,
per-step duration, and a bottom pane that tails the step's log
buffer as bytes come off stdout/stderr. `f` re-engages auto-follow;
any manual j/k pins you where you are.

Log streaming required rewriting all four runtimes. Emulation,
secure_emulation and podman move to `tokio::process::Command` with
piped stdio drained via `AsyncBufReadExt::lines()` in parallel
tasks. Docker is the fun one: `logs(follow: true)` now runs
concurrently with `wait_container` under `tokio::join!`, instead of
the old sequential wait-then-read. Chunks mirror into the sink
*and* accumulate into the final `ContainerOutput` so both the live
view and the post-mortem `StepResult.output` stay truthful.

CLI path is unchanged — `ExecutionConfig.event_sink` defaults to
`None` and the whole thing short-circuits. Reusable workflows and
composite actions collapse to a single synthetic step in the event
stream for v1; nested forwarding is a separate problem for another
day.

`StepStatus` gained `Pending` and `Running` variants with
`#[non_exhaustive]` so we can add more without breaking callers
again. Per-step log buffer is capped at 10k lines with a truncation
marker; the Step Output pane pins to the bottom so newest-first is
always visible without caring about scroll state.

Two new integration tests assert event ordering (JobStarted before
any of its StepStarted, StepCompleted before JobCompleted, pairs
match by (job_id, step_idx)) and that \`StepLogChunk\` arrives
*between* \`StepStarted\` and \`StepCompleted\`. The sink-absent
path stays behaviorally identical.

Please don't put back the "collect everything then show it" pattern
when the executor grows new capabilities. The streaming seams are
there now; use them.
2026-04-20 00:14:33 +05:30
..

wrkflw-executor

The execution engine that runs GitHub Actions workflows locally (Docker, Podman, or emulation).

  • Job graph execution with needs ordering and parallel independent jobs
  • Docker/Podman container steps and emulation mode
  • Run individual jobs via target_job / --job flag
  • GitHub Actions environment file support (GITHUB_OUTPUT, GITHUB_ENV, GITHUB_PATH, GITHUB_STEP_SUMMARY) with read-back
  • Docker-based action resolution (container, JavaScript, composite, local)
  • Job-level container: directive support
  • Used by: wrkflw CLI and TUI

API sketch

use wrkflw_executor::{execute_workflow, ExecutionConfig, RuntimeType};

let cfg = ExecutionConfig {
    runtime: RuntimeType::Docker,
    verbose: true,
    preserve_containers_on_failure: false,
    target_job: Some("build".to_string()), // run a single job
};

let workflow_path = std::path::Path::new(".github/workflows/ci.yml");
let result = execute_workflow(workflow_path, cfg).await?;
println!("workflow status: {:?}", result.summary_status);

Prefer using the wrkflw binary for a complete UX across validation, execution, and logs.