From 67863ebd52b4a78bd355a29c97b77b0781be1493 Mon Sep 17 00:00:00 2001 From: Robbie McKinstry Date: Thu, 25 Jun 2026 00:26:54 -0400 Subject: [PATCH] feat(check): live progress TUI for `multi check` via PresenterActor (MULTI-1369) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface what `multi check` is doing *while it runs*. A fourth, display-only Kameo actor (`PresenterActor`) consumes a fire-and-forget `UiEvent` stream emitted by the discovery/execution pipeline at each per-check milestone (queued → started → retrying → settled) and renders progress through a `RenderBackend` chosen at spawn from whether stdout is a TTY: - `InlineTuiBackend` (TTY): a Ratatui inline viewport — a requirement→check tree + progress gauge with per-check spinners and climbing elapsed timers (the liveness signal). Completed requirements flush into permanent scrollback via `insert_before` as they finish, so the viewport only holds in-flight work and the full record persists by end-of-run with no separate reprint. Never uses the alternate screen. - `HeartbeatBackend` (non-TTY): a slow-cadence one-line progress heartbeat on stderr so piped/CI runs show liveness. stdout is left untouched — the reporting actor's byte-for-byte report and 0/1 exit code are preserved exactly as MULTI-1368. - `NullBackend` / `RecordingBackend` (tests): headless. Events are a concrete enum (exhaustiveness, one `Message` impl, no per-message boxing); polymorphism lives on the backend. A separate periodic `Tick` drives redraws so animations keep moving between events. The presenter holds a single `PresenterState` view-model with from-scratch tally scans (retry-safe), and the flushed TTY record reuses the reporting actor's exact text + AND-aggregation, so the TTY scrollback and non-TTY stdout differ only in inter-requirement order (completion vs declaration), never per-requirement content. Terminal restore is covered on all four paths: `on_stop` (normal/error), a panic hook (panic), and an async-signal-safe SIGINT handler (Ctrl-C) that shows the cursor and exits 130. The redraw ticker is owned by the actor and joined on stop. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 168 +++++++++++++- Cargo.toml | 1 + src/checks/discovery/mod.rs | 12 +- src/checks/e2e.rs | 50 +++- src/checks/execution.rs | 36 ++- src/checks/mod.rs | 87 ++++++- src/checks/presenter/backend.rs | 42 ++++ src/checks/presenter/format.rs | 79 +++++++ src/checks/presenter/heartbeat.rs | 154 +++++++++++++ src/checks/presenter/inline.rs | 370 ++++++++++++++++++++++++++++++ src/checks/presenter/mod.rs | 269 ++++++++++++++++++++++ src/checks/presenter/recording.rs | 62 +++++ src/checks/presenter/state.rs | 292 +++++++++++++++++++++++ src/checks/reporting.rs | 29 ++- 14 files changed, 1617 insertions(+), 34 deletions(-) create mode 100644 src/checks/presenter/backend.rs create mode 100644 src/checks/presenter/format.rs create mode 100644 src/checks/presenter/heartbeat.rs create mode 100644 src/checks/presenter/inline.rs create mode 100644 src/checks/presenter/mod.rs create mode 100644 src/checks/presenter/recording.rs create mode 100644 src/checks/presenter/state.rs diff --git a/Cargo.lock b/Cargo.lock index 3439c27..b3aebb9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -957,6 +957,21 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.65" @@ -1311,7 +1326,7 @@ checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" dependencies = [ "serde", "termcolor", - "unicode-width 0.2.2", + "unicode-width 0.2.0", ] [[package]] @@ -1320,6 +1335,20 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "comrak" version = "0.52.0" @@ -1346,7 +1375,7 @@ dependencies = [ "encode_unicode", "libc", "once_cell", - "unicode-width 0.2.2", + "unicode-width 0.2.0", "windows-sys 0.59.0", ] @@ -1452,6 +1481,31 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.13.0", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -2681,6 +2735,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inlinable_string" version = "0.1.15" @@ -2707,6 +2770,19 @@ dependencies = [ "libc", ] +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "instant" version = "0.1.13" @@ -2746,6 +2822,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -3184,6 +3269,7 @@ dependencies = [ "pingora", "pretty_assertions", "rand 0.9.4", + "ratatui", "reqwest 0.12.28", "serde", "serde_json", @@ -3514,6 +3600,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pear" version = "0.2.9" @@ -4256,6 +4348,27 @@ dependencies = [ "rand 0.8.6", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.13.0", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools 0.13.0", + "lru 0.12.5", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "rayon" version = "1.12.0" @@ -5086,6 +5199,27 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -5215,6 +5349,9 @@ name = "strum" version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] [[package]] name = "strum_macros" @@ -5365,7 +5502,7 @@ dependencies = [ "fnv", "fs4", "htmlescape", - "itertools", + "itertools 0.12.1", "levenshtein_automata", "log", "lru 0.12.5", @@ -5414,7 +5551,7 @@ checksum = "12722224ffbe346c7fec3275c699e508fd0d4710e629e933d5736ec524a1f44e" dependencies = [ "downcast-rs 1.2.1", "fastdivide", - "itertools", + "itertools 0.12.1", "serde", "tantivy-bitpacker", "tantivy-common", @@ -5543,7 +5680,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" dependencies = [ "unicode-linebreak", - "unicode-width 0.2.2", + "unicode-width 0.2.0", ] [[package]] @@ -6125,6 +6262,23 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width 0.1.14", +] + [[package]] name = "unicode-width" version = "0.1.14" @@ -6133,9 +6287,9 @@ checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] name = "unicode-width" -version = "0.2.2" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] name = "untrusted" diff --git a/Cargo.toml b/Cargo.toml index 39d4562..fff7cff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,7 @@ miette = { workspace = true, features = ["fancy"] } mockall = "0.13.1" multitool-sdk = { git = "https://github.com/wack/multitool-rust-sdk.git", branch = "trunk" } pingora = { version = "0.3", features = ["lb", "proxy"], optional = true } +ratatui = "0.29" rand = { version = "0.9.0", features = ["small_rng"] } reqwest = { version = "0.12.15", default-features = false, features = [ "rustls-tls", diff --git a/src/checks/discovery/mod.rs b/src/checks/discovery/mod.rs index fc100a0..f6c686f 100644 --- a/src/checks/discovery/mod.rs +++ b/src/checks/discovery/mod.rs @@ -21,6 +21,7 @@ use multi_core::ManyError; use crate::checks::execution::ExecutionActor; use crate::checks::messages::{BeginDiscovery, DiscoveryFailed}; use crate::checks::model::Requirement; +use crate::checks::presenter::PresenterActor; use crate::checks::reporting::ReportingActor; /// Run the full discovery phase rooted at `root`. @@ -69,6 +70,7 @@ pub(crate) struct DiscoveryActor { root: PathBuf, execution: ActorRef, reporting: ActorRef, + presenter: ActorRef, } impl Actor for DiscoveryActor { @@ -88,11 +90,13 @@ impl DiscoveryActor { root: PathBuf, execution: ActorRef, reporting: ActorRef, + presenter: ActorRef, ) -> Self { Self { root, execution, reporting, + presenter, } } } @@ -103,8 +107,12 @@ impl Message for DiscoveryActor { async fn handle(&mut self, _msg: BeginDiscovery, _ctx: &mut Context) -> Self::Reply { match discover(&self.root).await { Ok(requirements) => { - if let Err(err) = - crate::checks::stream_requirements(&self.execution, &requirements).await + if let Err(err) = crate::checks::stream_requirements( + &self.execution, + &self.presenter, + &requirements, + ) + .await { tracing::error!(?err, "failed to stream discovered checks to execution"); } diff --git a/src/checks/e2e.rs b/src/checks/e2e.rs index 13a55b7..d1cd204 100644 --- a/src/checks/e2e.rs +++ b/src/checks/e2e.rs @@ -21,6 +21,7 @@ use crate::checks::discovery::discover; use crate::checks::executor::{ AgentOutcome, AgentRunRequest, CheckExecutor, CheckReport, FakeExecutor, }; +use crate::checks::presenter::null_backend; use crate::checks::reporting::report; use crate::checks::sandbox::NoopSandbox; use crate::checks::{run_pipeline, run_to_outcomes}; @@ -91,9 +92,16 @@ async fn pipeline_satisfied_failed_multi_and_anonymous() { let fake = Arc::new(fake); let cfg = configuration(); - let outcomes = run_to_outcomes(&cfg, fake.clone(), Arc::new(NoopSandbox), dir.path(), &reqs) - .await - .unwrap(); + let outcomes = run_to_outcomes( + &cfg, + fake.clone(), + Arc::new(NoopSandbox), + dir.path(), + &reqs, + null_backend(), + ) + .await + .unwrap(); // Aggregated verdicts: satisfied / satisfied(AND) / failed. assert!( @@ -127,9 +135,16 @@ async fn all_satisfied_exits_zero() { let fake = Arc::new(FakeExecutor::new().with_report(0, true, None)); let cfg = configuration(); - let outcomes = run_to_outcomes(&cfg, fake, Arc::new(NoopSandbox), dir.path(), &reqs) - .await - .unwrap(); + let outcomes = run_to_outcomes( + &cfg, + fake, + Arc::new(NoopSandbox), + dir.path(), + &reqs, + null_backend(), + ) + .await + .unwrap(); assert!(outcomes[0].satisfied); let code = report(&plain_terminal(), &outcomes).unwrap(); @@ -149,6 +164,7 @@ async fn empty_tree_exits_zero() { Arc::new(NoopSandbox), dir.path(), &reqs, + null_backend(), ) .await .unwrap(); @@ -208,9 +224,16 @@ async fn checks_execute_concurrently_not_in_a_barrier() { let cfg = configuration(); assert_eq!(cfg.concurrency, 2, "test assumes a concurrency of 2"); - let outcomes = run_to_outcomes(&cfg, executor, Arc::new(NoopSandbox), dir.path(), &reqs) - .await - .unwrap(); + let outcomes = run_to_outcomes( + &cfg, + executor, + Arc::new(NoopSandbox), + dir.path(), + &reqs, + null_backend(), + ) + .await + .unwrap(); // Both satisfied ⇒ both ran simultaneously (the barrier tripped). assert!( @@ -235,7 +258,14 @@ async fn invalid_suite_aborts_run_without_spawning_agents() { // whole-run abort path is exercised end-to-end. let fake = Arc::new(FakeExecutor::new()); let cfg = configuration(); - let result = run_pipeline(&cfg, fake.clone(), Arc::new(NoopSandbox), dir.path()).await; + let result = run_pipeline( + &cfg, + fake.clone(), + Arc::new(NoopSandbox), + dir.path(), + null_backend(), + ) + .await; // The run aborts as an `Err` diagnostic (so CI distinguishes "tool errored" // from "checks failed"), and — crucially — no agent was ever spawned. diff --git a/src/checks/execution.rs b/src/checks/execution.rs index be31433..af84c91 100644 --- a/src/checks/execution.rs +++ b/src/checks/execution.rs @@ -28,6 +28,7 @@ use crate::checks::messages::{ CheckCompleted, CheckDiscovered, CheckJob, DiscoveryComplete, ExecutionComplete, RetryCheck, }; use crate::checks::model::{Check, CheckId, CheckOutcome, Verdict}; +use crate::checks::presenter::{PresenterActor, UiEvent}; use crate::checks::reporting::ReportingActor; use crate::checks::sandbox::Sandbox; @@ -43,6 +44,8 @@ pub(crate) struct ExecutionActor { max_attempts: usize, /// The downstream reporting actor. reporting: ActorRef, + /// The display-only presenter, told of each check's lifecycle milestones. + presenter: ActorRef, } impl Actor for ExecutionActor { @@ -66,6 +69,7 @@ impl ExecutionActor { concurrency: usize, max_attempts: usize, reporting: ActorRef, + presenter: ActorRef, ) -> Self { Self { executor, @@ -74,6 +78,7 @@ impl ExecutionActor { semaphore: Arc::new(Semaphore::new(concurrency.max(1))), max_attempts: max_attempts.max(1), reporting, + presenter, } } @@ -86,7 +91,9 @@ impl ExecutionActor { let semaphore = self.semaphore.clone(); let working_dir = self.working_dir.clone(); let reporting = self.reporting.clone(); + let presenter = self.presenter.clone(); let me = ctx.actor_ref().clone(); + let id = job.id; tokio::spawn(async move { // Acquire the permit inside the task. If the semaphore was closed the @@ -95,11 +102,21 @@ impl ExecutionActor { return; }; + // Permit acquired ⇒ the agent is about to run: mark the check Running. + let _ = presenter.tell(UiEvent::CheckStarted { id }).await; + let result = run_one(executor, sandbox, job.id, job.check.clone(), &working_dir).await; if has_verdict(Some(&result)) { - // The agent reported: reconcile and forward straight to reporting. + // The agent reported: reconcile, surface the verdict to the + // presenter, and forward straight to reporting. let outcome = reconcile(Some(&result), &job.check.title); + let _ = presenter + .tell(UiEvent::CheckSettled { + id, + outcome: outcome.clone(), + }) + .await; if let Err(err) = reporting.tell(CheckCompleted { job, outcome }).await { tracing::debug!(?err, "reporting actor unavailable for completed check"); } @@ -120,6 +137,13 @@ impl ExecutionActor { /// an errored outcome. async fn finish_errored(&self, job: CheckJob, last: Result) { let outcome = reconcile(Some(&last), &job.check.title); + let _ = self + .presenter + .tell(UiEvent::CheckSettled { + id: job.id, + outcome: outcome.clone(), + }) + .await; if let Err(err) = self.reporting.tell(CheckCompleted { job, outcome }).await { tracing::debug!(?err, "reporting actor unavailable for errored check"); } @@ -145,6 +169,13 @@ impl Message for ExecutionActor { attempt = attempt + 1, "retrying check whose agent did not report" ); + let _ = self + .presenter + .tell(UiEvent::CheckRetrying { + id: job.id, + attempt: attempt as u32, + }) + .await; self.dispatch(ctx, job, attempt + 1); } else { self.finish_errored(job, last).await; @@ -308,6 +339,7 @@ mod tests { Arc::new(NoopSandbox), &PathBuf::from("."), &reqs, + crate::checks::presenter::null_backend(), ) .await .unwrap(); @@ -336,6 +368,7 @@ mod tests { Arc::new(NoopSandbox), &PathBuf::from("."), &reqs, + crate::checks::presenter::null_backend(), ) .await .unwrap(); @@ -356,6 +389,7 @@ mod tests { Arc::new(NoopSandbox), &PathBuf::from("."), &reqs, + crate::checks::presenter::null_backend(), ) .await .unwrap(); diff --git a/src/checks/mod.rs b/src/checks/mod.rs index bb6f77a..80dcc91 100644 --- a/src/checks/mod.rs +++ b/src/checks/mod.rs @@ -30,6 +30,7 @@ mod execution; pub mod executor; mod messages; pub mod model; +mod presenter; mod reporting; pub mod sandbox; @@ -50,6 +51,7 @@ use crate::checks::execution::ExecutionActor; use crate::checks::executor::CheckExecutor; use crate::checks::messages::{BeginDiscovery, CheckDiscovered, CheckJob, DiscoveryComplete}; use crate::checks::model::{Requirement, RequirementOutcome}; +use crate::checks::presenter::{PresenterActor, RenderBackend, UiEvent, select_backend}; use crate::checks::reporting::{ReportingActor, RunResult}; use crate::checks::sandbox::Sandbox; @@ -76,25 +78,52 @@ pub async fn run(terminal: &Terminal, working_dir: &Path, overrides: CliOverride let executor: Arc = Arc::from(resolved.build_executor()?); let sandbox: Arc = Arc::from(sandbox::select_sandbox()); - // Phases 2–4: drive the actor pipeline to its terminal result, then render. - let outcomes = run_pipeline(&resolved.config, executor, sandbox, working_dir).await?; - reporting::report(terminal, &outcomes) + // Spawn the live presenter's backend up front (the inline viewport reserves + // its terminal region immediately): inline TUI in a TTY, stderr heartbeat + // otherwise. `owns_record` (true only for the inline TUI) routes the final + // record below. + let presenter::Backend { + backend, + owns_record, + } = select_backend(terminal.stdout_allows_color()); + + // Phases 2–5: drive the actor pipeline (with the presenter) to its terminal + // result, then render the record. + let outcomes = run_pipeline(&resolved.config, executor, sandbox, working_dir, backend).await?; + + if owns_record { + // The inline TUI was the sole terminal writer and has already flushed the + // full record into scrollback; writing it again would double-print. Just + // compute the exit code. + Ok(reporting::exit_code(&outcomes)) + } else { + // Heartbeat / no TTY: the reporting actor owns stdout — byte-for-byte as + // MULTI-1368. + reporting::report(terminal, &outcomes) + } } -/// Spawn the reporting + execution actors and return their refs plus the channel -/// the terminal result arrives on. Refs must be kept alive by the caller until -/// the result is received (dropping the last ref stops the actor). +/// Spawn the presenter + reporting + execution actors and return their refs plus +/// the channel the terminal result arrives on. Refs must be kept alive by the +/// caller until the result is received (dropping the last ref stops the actor). +/// +/// The presenter is display-only: it isn't in the `tell` pipeline, but execution +/// holds its ref to fire-and-forget [`UiEvent`]s. The `backend` chooses where +/// those events surface (inline TUI / heartbeat / no-op). fn spawn_core( cfg: &Config, executor: Arc, sandbox: Arc, working_dir: &Path, + backend: Box, ) -> ( ActorRef, ActorRef, + ActorRef, oneshot::Receiver, ) { let (tx, rx) = oneshot::channel(); + let presenter = PresenterActor::spawn(PresenterActor::new(backend)); let reporting = ReportingActor::spawn(ReportingActor::new(tx)); let execution = ExecutionActor::spawn(ExecutionActor::new( executor, @@ -103,8 +132,17 @@ fn spawn_core( cfg.concurrency, cfg.max_attempts, reporting.clone(), + presenter.clone(), )); - (execution, reporting, rx) + (execution, reporting, presenter, rx) +} + +/// Stop the presenter and wait for its teardown to finish. `stop_gracefully` +/// drains any `UiEvent`s still in its mailbox first, so the terminal restore / +/// final scrollback flush sees a complete state. +async fn shutdown_presenter(presenter: &ActorRef) { + let _ = presenter.stop_gracefully().await; + presenter.wait_for_shutdown().await; } /// Stream a (validated) requirement set into the execution actor: one @@ -117,12 +155,23 @@ fn spawn_core( /// [`CheckId`]: crate::checks::model::CheckId async fn stream_requirements( execution: &ActorRef, + presenter: &ActorRef, requirements: &[Requirement], ) -> Result<()> { let mut id = 0; let mut total = 0; for (req_index, req) in requirements.iter().enumerate() { for check in &req.checks { + // Tell the presenter about the check first so its tree row exists + // before execution can emit `CheckStarted` for it. + let _ = presenter + .tell(UiEvent::CheckQueued { + id, + req_index, + req_title: req.title.clone(), + check_title: check.title.clone(), + }) + .await; let job = CheckJob { id, req_index, @@ -138,6 +187,11 @@ async fn stream_requirements( total += 1; } } + let _ = presenter + .tell(UiEvent::DiscoveryComplete { + total_checks: total, + }) + .await; execution .tell(DiscoveryComplete { total_checks: total, @@ -167,12 +221,15 @@ async fn run_pipeline( executor: Arc, sandbox: Arc, working_dir: &Path, + backend: Box, ) -> Result> { - let (execution, reporting, rx) = spawn_core(cfg, executor, sandbox, working_dir); + let (execution, reporting, presenter, rx) = + spawn_core(cfg, executor, sandbox, working_dir, backend); let discovery = DiscoveryActor::spawn(DiscoveryActor::new( working_dir.to_path_buf(), execution.clone(), reporting.clone(), + presenter.clone(), )); discovery .tell(BeginDiscovery) @@ -181,9 +238,12 @@ async fn run_pipeline( .map_err(|e| miette!("failed to start discovery: {e}"))?; let outcomes = await_result(rx).await; + // The run is over: drain the presenter's mailbox and run its teardown + // (terminal restore / final scrollback flush) before returning. + shutdown_presenter(&presenter).await; // Keep refs alive across the await; dropping them earlier would stop the // actors mid-run. - drop((discovery, execution, reporting)); + drop((discovery, execution, reporting, presenter)); outcomes } @@ -198,10 +258,13 @@ async fn run_to_outcomes( sandbox: Arc, working_dir: &Path, requirements: &[Requirement], + backend: Box, ) -> Result> { - let (execution, reporting, rx) = spawn_core(cfg, executor, sandbox, working_dir); - stream_requirements(&execution, requirements).await?; + let (execution, reporting, presenter, rx) = + spawn_core(cfg, executor, sandbox, working_dir, backend); + stream_requirements(&execution, &presenter, requirements).await?; let outcomes = await_result(rx).await; - drop((execution, reporting)); + shutdown_presenter(&presenter).await; + drop((execution, reporting, presenter)); outcomes } diff --git a/src/checks/presenter/backend.rs b/src/checks/presenter/backend.rs new file mode 100644 index 0000000..1cdd618 --- /dev/null +++ b/src/checks/presenter/backend.rs @@ -0,0 +1,42 @@ +//! The polymorphic seam (MULTI-1369). +//! +//! Events are a concrete [`UiEvent`] enum; the *backend* is where rendering +//! polymorphism lives. The presenter holds a `Box` chosen at +//! startup from whether stdout is a TTY: +//! +//! * [`InlineTuiBackend`] — stdout is a TTY: a Ratatui inline viewport. +//! * [`HeartbeatBackend`] — not a TTY: a periodic stderr progress line. +//! * [`NullBackend`] / [`RecordingBackend`] — tests: no terminal. +//! +//! [`UiEvent`]: super::UiEvent +//! [`InlineTuiBackend`]: super::inline::InlineTuiBackend +//! [`HeartbeatBackend`]: super::heartbeat::HeartbeatBackend +//! [`NullBackend`]: super::recording::NullBackend +//! [`RecordingBackend`]: super::recording::RecordingBackend + +use std::time::Duration; + +use super::UiEvent; +use super::state::PresenterState; + +/// A render target for the live presenter. Implementations are display-only: +/// they read [`PresenterState`] + the latest [`UiEvent`] and surface progress; +/// they never feed input back into the pipeline. +pub(crate) trait RenderBackend: Send { + /// React to a single event *after* it has been folded into `state`. Used for + /// event-driven work (e.g. flushing a completed requirement to scrollback); + /// state-derived rendering belongs in [`RenderBackend::tick`]. + fn apply(&mut self, state: &PresenterState, event: &UiEvent); + + /// Redraw on the periodic clock — this is what keeps spinners and the + /// elapsed-time counters moving between events (the liveness signal). + fn tick(&mut self, state: &PresenterState); + + /// Final cleanup: flush any pending record, clear ephemeral UI, and restore + /// the terminal. Must be idempotent — it runs from the actor's `on_stop`. + fn teardown(&mut self, state: &PresenterState); + + /// How often [`RenderBackend::tick`] should fire. Fast for the TUI (smooth + /// spinners); slow for the heartbeat (one tidy line every several seconds). + fn tick_interval(&self) -> Duration; +} diff --git a/src/checks/presenter/format.rs b/src/checks/presenter/format.rs new file mode 100644 index 0000000..60529b5 --- /dev/null +++ b/src/checks/presenter/format.rs @@ -0,0 +1,79 @@ +//! Small pure formatters shared by the live backends (MULTI-1369): the spinner, +//! the two elapsed-time renderings, and the textual progress gauge. Kept pure and +//! unit-tested so the (visually un-testable) backends can lean on them. + +use std::time::Duration; + +/// Braille spinner frames, one per tick, for a Running check. +pub(crate) const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +/// The spinner glyph for a given frame counter. +pub(crate) fn spinner_frame(frame: u64) -> &'static str { + SPINNER[(frame as usize) % SPINNER.len()] +} + +/// Compact elapsed time for the heartbeat header, e.g. `45s`, `3m12s`, `1h04m`. +pub(crate) fn human_elapsed(d: Duration) -> String { + let secs = d.as_secs(); + let (h, m, s) = (secs / 3600, (secs % 3600) / 60, secs % 60); + if h > 0 { + format!("{h}h{m:02}m") + } else if m > 0 { + format!("{m}m{s:02}s") + } else { + format!("{s}s") + } +} + +/// Clock-style elapsed time for a tree leaf, e.g. `0:12`, `3:05`, `1:02:09`. +pub(crate) fn clock_elapsed(d: Duration) -> String { + let secs = d.as_secs(); + let (h, m, s) = (secs / 3600, (secs % 3600) / 60, secs % 60); + if h > 0 { + format!("{h}:{m:02}:{s:02}") + } else { + format!("{m}:{s:02}") + } +} + +/// A textual progress bar like `▕███████░░░░░▏`, `width` cells wide. With an +/// unknown or zero total the bar renders empty. +pub(crate) fn gauge_bar(done: usize, total: usize, width: usize) -> String { + let filled = if total == 0 { + 0 + } else if done >= total { + width + } else { + // Round to the nearest cell, but never report "full" until truly done. + let exact = (done * width) as f64 / total as f64; + (exact.round() as usize).min(width.saturating_sub(1)) + }; + let empty = width.saturating_sub(filled); + format!("▕{}{}▏", "█".repeat(filled), "░".repeat(empty)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn elapsed_renderings() { + assert_eq!(human_elapsed(Duration::from_secs(45)), "45s"); + assert_eq!(human_elapsed(Duration::from_secs(192)), "3m12s"); + assert_eq!(human_elapsed(Duration::from_secs(3840)), "1h04m"); + assert_eq!(clock_elapsed(Duration::from_secs(12)), "0:12"); + assert_eq!(clock_elapsed(Duration::from_secs(185)), "3:05"); + assert_eq!(clock_elapsed(Duration::from_secs(3729)), "1:02:09"); + } + + #[test] + fn gauge_fills_proportionally_and_caps() { + assert_eq!(gauge_bar(0, 12, 12), "▕░░░░░░░░░░░░▏"); + assert_eq!(gauge_bar(12, 12, 12), "▕████████████▏"); + // Partial progress never shows a full bar. + let bar = gauge_bar(11, 12, 12); + assert!(bar.contains('░'), "in-progress bar must not be full: {bar}"); + // Unknown total ⇒ empty bar. + assert_eq!(gauge_bar(3, 0, 6), "▕░░░░░░▏"); + } +} diff --git a/src/checks/presenter/heartbeat.rs b/src/checks/presenter/heartbeat.rs new file mode 100644 index 0000000..bf90114 --- /dev/null +++ b/src/checks/presenter/heartbeat.rs @@ -0,0 +1,154 @@ +//! The non-TTY backend (MULTI-1369). +//! +//! When stdout is not a terminal (pipe, redirect, CI), there is no live UI — but +//! a silent run still reads as a hang. [`HeartbeatBackend`] closes that gap with +//! a compact progress line on **stderr** at a slow cadence, leaving stdout +//! reserved for the reporting actor's byte-for-byte report. +//! +//! * If **stderr is a TTY**, overwrite one tidy line in place with `\r`, and clear +//! it on teardown so nothing lingers before the final report. +//! * If **stderr is not a TTY** (the CI-log case), append a fresh line each +//! interval so the log preserves progression. +//! +//! The cadence is slow (`HEARTBEAT_INTERVAL`) on purpose: short runs — including +//! the millisecond fake-executor tests — emit nothing at all. + +use std::io::Write; +use std::time::Duration; + +use super::UiEvent; +use super::backend::RenderBackend; +use super::format::human_elapsed; +use super::state::PresenterState; + +/// How often the heartbeat line refreshes. Slow enough that fast runs stay quiet. +const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(10); + +/// Emits a periodic one-line progress heartbeat to stderr. +pub(crate) struct HeartbeatBackend { + /// Whether stderr is a TTY: in-place (`\r`) vs append-a-line. + stderr_is_tty: bool, + /// Whether an in-place line is currently on screen (so teardown can clear it). + line_pending: bool, +} + +impl HeartbeatBackend { + pub(crate) fn new(stderr_is_tty: bool) -> Self { + Self { + stderr_is_tty, + line_pending: false, + } + } + + /// The heartbeat text, e.g. `[multi] 7/12 checks complete · 4 running · 3m12s`. + /// Returns `None` before there's anything meaningful to report. + fn line(&self, state: &PresenterState) -> Option { + let total = state.total?; + if total == 0 { + return None; + } + let elapsed = human_elapsed(state.run_started.elapsed()); + Some(format!( + "[multi] {}/{total} checks complete · {} running · {elapsed}", + state.done(), + state.running(), + )) + } + + fn emit(&mut self, state: &PresenterState) { + let Some(line) = self.line(state) else { + return; + }; + let mut err = std::io::stderr().lock(); + if self.stderr_is_tty { + // Overwrite in place; clear to end-of-line in case the prior line was + // longer (`\x1b[K`). + let _ = write!(err, "\r{line}\x1b[K"); + } else { + let _ = writeln!(err, "{line}"); + } + let _ = err.flush(); + self.line_pending = true; + } +} + +impl RenderBackend for HeartbeatBackend { + fn apply(&mut self, _state: &PresenterState, _event: &UiEvent) { + // Heartbeat is purely time-driven; events only update shared state. + } + + fn tick(&mut self, state: &PresenterState) { + self.emit(state); + } + + fn teardown(&mut self, _state: &PresenterState) { + // Clear the in-place line so it never collides with the final report. + if self.stderr_is_tty && self.line_pending { + let mut err = std::io::stderr().lock(); + let _ = write!(err, "\r\x1b[K"); + let _ = err.flush(); + } + self.line_pending = false; + } + + fn tick_interval(&self) -> Duration { + HEARTBEAT_INTERVAL + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::checks::model::{CheckOutcome, Verdict}; + + fn queued(state: &mut PresenterState, id: usize) { + state.apply(&UiEvent::CheckQueued { + id, + req_index: 0, + req_title: "R".into(), + check_title: "c".into(), + }); + } + + #[test] + fn no_line_until_total_is_known() { + let backend = HeartbeatBackend::new(false); + let mut state = PresenterState::new(); + queued(&mut state, 0); + // Total unknown ⇒ nothing to print yet. + assert!(backend.line(&state).is_none()); + + state.apply(&UiEvent::DiscoveryComplete { total_checks: 2 }); + let line = backend.line(&state).expect("line once total is known"); + assert!(line.starts_with("[multi] 0/2 checks complete"), "{line}"); + } + + #[test] + fn line_reflects_done_and_running_counts() { + let backend = HeartbeatBackend::new(false); + let mut state = PresenterState::new(); + queued(&mut state, 0); + queued(&mut state, 1); + state.apply(&UiEvent::DiscoveryComplete { total_checks: 2 }); + state.apply(&UiEvent::CheckStarted { id: 0 }); + state.apply(&UiEvent::CheckSettled { + id: 1, + outcome: CheckOutcome { + title: "c".into(), + verdict: Verdict::Satisfied, + evidence: None, + }, + }); + let line = backend.line(&state).unwrap(); + assert!(line.contains("1/2 checks complete"), "{line}"); + assert!(line.contains("1 running"), "{line}"); + } + + #[test] + fn empty_suite_emits_nothing() { + let backend = HeartbeatBackend::new(true); + let mut state = PresenterState::new(); + state.apply(&UiEvent::DiscoveryComplete { total_checks: 0 }); + assert!(backend.line(&state).is_none()); + } +} diff --git a/src/checks/presenter/inline.rs b/src/checks/presenter/inline.rs new file mode 100644 index 0000000..5ce42ea --- /dev/null +++ b/src/checks/presenter/inline.rs @@ -0,0 +1,370 @@ +//! The TTY backend (MULTI-1369): a Ratatui **inline viewport**. +//! +//! The live requirement→check tree and progress gauge occupy a bounded region at +//! the bottom of the *normal* terminal buffer (never the alternate screen). As +//! each requirement's checks all settle, its rendered result line(s) are flushed +//! into permanent scrollback above the shrinking live region via +//! [`Terminal::insert_before`]. That one mechanism does triple duty — overflow +//! (the viewport only ever holds what's still in flight), liveness (results +//! appear durably the moment they're known), and persistence (by end-of-run the +//! full record already sits in scrollback, so there's **no separate final +//! reprint**). See the ticket for why this beats the alternate screen. +//! +//! The flushed record reuses the reporting actor's exact text (the requirement +//! line + [`failing_check_text`]) and the same AND-aggregation, so the TTY +//! scrollback and the non-TTY stdout report differ only in inter-requirement +//! order (completion vs declaration), never in per-requirement content. +//! +//! [`Terminal::insert_before`]: ratatui::Terminal::insert_before +//! [`failing_check_text`]: crate::checks::reporting::failing_check_text + +use std::collections::HashSet; +use std::io::{self, Stdout, Write}; +use std::sync::Once; +use std::time::Duration; + +use ratatui::backend::CrosstermBackend; +use ratatui::crossterm::{cursor, execute}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::Line; +use ratatui::widgets::{Paragraph, Widget}; +use ratatui::{Terminal, TerminalOptions, Viewport}; + +use crate::checks::model::{RequirementOutcome, Verdict}; +use crate::checks::reporting::failing_check_text; + +use super::UiEvent; +use super::backend::RenderBackend; +use super::format::{clock_elapsed, gauge_bar, human_elapsed, spinner_frame}; +use super::state::{CheckState, PresenterState}; + +/// Reserved height for the live region. Completed requirements flush out, so this +/// only ever needs to hold the in-flight tree + the gauge header. +const VIEWPORT_HEIGHT: u16 = 12; +/// Width of the textual gauge bar. +const GAUGE_WIDTH: usize = 12; +/// Redraw cadence: ~20fps keeps the spinner and elapsed timers fluid. +const TICK: Duration = Duration::from_millis(50); + +/// Inline-viewport TUI backend. Sole terminal writer for the whole TTY run. +pub(crate) struct InlineTuiBackend { + terminal: Terminal>, + /// Whether to color the flushed record (honors `--enable-colors`). + color: bool, + /// Requirements already flushed to scrollback (so they leave the live tree). + flushed: HashSet, + /// Spinner frame counter, advanced each tick. + frame: u64, + /// Guards teardown idempotency. + torn_down: bool, +} + +impl InlineTuiBackend { + /// Enter the inline viewport (hiding the cursor). Returns an `io::Error` if + /// terminal setup fails, so the caller can fall back to the heartbeat. + pub(crate) fn new(color: bool) -> io::Result { + install_terminal_guards(); + let mut stdout = io::stdout(); + execute!(stdout, cursor::Hide)?; + let backend = CrosstermBackend::new(stdout); + let terminal = Terminal::with_options( + backend, + TerminalOptions { + viewport: Viewport::Inline(VIEWPORT_HEIGHT), + }, + )?; + Ok(Self { + terminal, + color, + flushed: HashSet::new(), + frame: 0, + torn_down: false, + }) + } + + /// Flush every requirement that has fully settled (and isn't already flushed) + /// into scrollback, in `req_index` order. Only runs once discovery is complete + /// so a requirement's check count is final. + fn flush_completed(&mut self, state: &PresenterState) { + if !state.discovery_complete { + return; + } + for req_index in state.requirement_indices() { + if self.flushed.contains(&req_index) || !state.requirement_complete(req_index) { + continue; + } + let Some(outcome) = state.requirement_outcome(req_index) else { + continue; + }; + let lines = requirement_record_lines(&outcome, self.color); + let height = lines.len() as u16; + let _ = self.terminal.insert_before(height, move |buf| { + let area = buf.area; + Paragraph::new(lines).render(area, buf); + }); + self.flushed.insert(req_index); + } + } + + /// Redraw the live region (gauge header + in-flight tree). + fn draw_live(&mut self, state: &PresenterState) { + let lines = live_lines(state, &self.flushed, self.frame, GAUGE_WIDTH); + let _ = self.terminal.draw(|f| { + let area = f.area(); + f.render_widget(Paragraph::new(lines), area); + }); + } +} + +impl RenderBackend for InlineTuiBackend { + fn apply(&mut self, state: &PresenterState, event: &UiEvent) { + if self.torn_down { + return; + } + // A settle (or the discovery-complete gate opening) can complete a + // requirement; flush it to scrollback immediately so the record is durable. + if matches!( + event, + UiEvent::CheckSettled { .. } | UiEvent::DiscoveryComplete { .. } + ) { + self.flush_completed(state); + } + } + + fn tick(&mut self, state: &PresenterState) { + if self.torn_down { + return; + } + self.frame = self.frame.wrapping_add(1); + self.draw_live(state); + } + + fn teardown(&mut self, state: &PresenterState) { + if self.torn_down { + return; + } + self.torn_down = true; + // Flush any stragglers (by RunComplete all requirements have settled). + self.flush_completed(state); + // Mirror the reporting actor's empty-suite line. + if state.rows.is_empty() { + let _ = self.terminal.insert_before(1, |buf| { + let area = buf.area; + Paragraph::new(Line::raw("No requirements found.")).render(area, buf); + }); + } + // Remove the live viewport, then restore the cursor. + let _ = self.terminal.clear(); + let mut stdout = io::stdout(); + let _ = execute!(stdout, cursor::Show); + let _ = stdout.flush(); + } + + fn tick_interval(&self) -> Duration { + TICK + } +} + +/// The escape sequence that shows the cursor (`CSI ? 25 h`). +const SHOW_CURSOR: &[u8] = b"\x1b[?25h"; + +/// Restore the cursor on the two paths that bypass `on_stop` — a panic and a +/// SIGINT (Ctrl-C). Normal/error exits restore it in [`InlineTuiBackend::teardown`] +/// via `on_stop`. Installed once. +fn install_terminal_guards() { + static GUARDS: Once = Once::new(); + GUARDS.call_once(|| { + // Panic: show the cursor, then chain to the previous hook. + let prev = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let mut stdout = io::stdout(); + let _ = execute!(stdout, cursor::Show); + let _ = stdout.flush(); + prev(info); + })); + + // SIGINT: the default handler would terminate immediately, leaving the + // cursor hidden. Replace it with an async-signal-safe restore-and-exit. + // SAFETY: `restore_on_sigint` only calls async-signal-safe libc functions + // (`write`, `_exit`). + unsafe { + libc::signal( + libc::SIGINT, + restore_on_sigint as *const () as libc::sighandler_t, + ); + } + }); +} + +/// Async-signal-safe SIGINT handler: write the show-cursor sequence straight to +/// the stdout fd and exit 130 (the conventional 128 + SIGINT code). +extern "C" fn restore_on_sigint(_sig: libc::c_int) { + // SAFETY: `write` and `_exit` are async-signal-safe; the buffer is static. + unsafe { + libc::write( + libc::STDOUT_FILENO, + SHOW_CURSOR.as_ptr().cast(), + SHOW_CURSOR.len(), + ); + libc::_exit(130); + } +} + +/// The persistent record for one completed requirement — byte-identical in text +/// to the reporting actor's output, only styled for the terminal. +fn requirement_record_lines(outcome: &RequirementOutcome, color: bool) -> Vec> { + let mut lines = Vec::new(); + let title = outcome.title.clone(); + lines.push(if color { + let mut style = Style::new().add_modifier(Modifier::BOLD); + style = style.fg(if outcome.satisfied { + Color::Green + } else { + Color::Red + }); + Line::styled(title, style) + } else { + let mark = if outcome.satisfied { "PASS" } else { "FAIL" }; + Line::raw(format!("[{mark}] {title}")) + }); + if !outcome.satisfied { + for check in outcome.failing_checks() { + let text = failing_check_text(check); + lines.push(if color { + Line::styled(text, Style::new().fg(Color::Red)) + } else { + Line::raw(text) + }); + } + } + lines +} + +/// Build the live region: the gauge/tally header, then the in-flight tree grouped +/// by requirement (flushed requirements excluded). Requirements with active work +/// are ordered first so a bounded viewport never hides a running check. +fn live_lines( + state: &PresenterState, + flushed: &HashSet, + frame: u64, + gauge_width: usize, +) -> Vec> { + let mut lines = Vec::new(); + + let done = state.done(); + let total = state.total.unwrap_or(0); + let (sat, failed, errored) = state.verdict_tallies(); + let bar = gauge_bar(done, total, gauge_width); + let total_str = state + .total + .map(|t| t.to_string()) + .unwrap_or_else(|| "?".to_string()); + let elapsed = human_elapsed(state.run_started.elapsed()); + lines.push(Line::raw(format!( + "checks {bar} {done}/{total_str} ✓{sat} ✗{failed} ⚠{errored} · {} running · {elapsed}", + state.running(), + ))); + + // Order: requirements with any running/retrying check first (liveness), then + // by declaration order. Flushed requirements are gone from the tree. + let mut reqs: Vec = state + .requirement_indices() + .into_iter() + .filter(|r| !flushed.contains(r)) + .collect(); + reqs.sort_by_key(|&r| { + let active = state + .requirement_rows(r) + .iter() + .any(|row| matches!(row.state, CheckState::Running | CheckState::Retrying(_))); + (!active, r) + }); + + for (i, &req_index) in reqs.iter().enumerate() { + let rows = state.requirement_rows(req_index); + let req_title = rows + .first() + .map(|r| r.req_title.clone()) + .unwrap_or_default(); + let settled = rows + .iter() + .filter(|r| matches!(r.state, CheckState::Settled(_))) + .count(); + let last_req = i + 1 == reqs.len(); + let parent_branch = if last_req { "└─ " } else { "├─ " }; + lines.push(Line::raw(format!( + "{parent_branch}{req_title} ({settled}/{})", + rows.len() + ))); + + let cont = if last_req { " " } else { "│ " }; + for (j, row) in rows.iter().enumerate() { + let last_child = j + 1 == rows.len(); + let child_branch = if last_child { "└─ " } else { "├─ " }; + let glyph = leaf_glyph(&row.state, frame); + let label = format!("{cont}{child_branch}{glyph} {}", row.check_title); + let elapsed = row + .started + .map(|s| clock_elapsed(s.elapsed())) + .unwrap_or_default(); + lines.push(Line::raw(format!("{label:<44}{elapsed}"))); + } + } + + lines +} + +/// The leaf glyph for a check: spinner while active, verdict mark once settled. +fn leaf_glyph(state: &CheckState, frame: u64) -> String { + match state { + CheckState::Queued => "·".to_string(), + CheckState::Running | CheckState::Retrying(_) => spinner_frame(frame).to_string(), + CheckState::Settled(Verdict::Satisfied) => "✓".to_string(), + CheckState::Settled(Verdict::Failed) => "✗".to_string(), + CheckState::Settled(Verdict::Errored) => "⚠".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::checks::model::CheckOutcome; + use crate::checks::reporting::{failing_check_text as report_text, format_requirement_plain}; + + fn outcome(title: &str, satisfied: bool, checks: Vec) -> RequirementOutcome { + RequirementOutcome { + title: title.into(), + filepath: std::path::PathBuf::new(), + satisfied, + check_outcomes: checks, + } + } + + /// The flushed record's plain text must match the reporting actor's output — + /// one record, never divergent. + #[test] + fn flushed_record_text_matches_reporting() { + let failing = CheckOutcome { + title: "c".into(), + verdict: Verdict::Failed, + evidence: Some("nope".into()), + }; + let out = outcome("R", false, vec![failing.clone()]); + + let lines = requirement_record_lines(&out, false); + let rendered: Vec = lines + .iter() + .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect()) + .collect(); + + assert_eq!(rendered[0], format_requirement_plain(&out)); + assert_eq!(rendered[1], report_text(&failing)); + } + + #[test] + fn satisfied_record_is_single_line() { + let out = outcome("OK", true, vec![]); + let lines = requirement_record_lines(&out, false); + assert_eq!(lines.len(), 1); + } +} diff --git a/src/checks/presenter/mod.rs b/src/checks/presenter/mod.rs new file mode 100644 index 0000000..952bf8a --- /dev/null +++ b/src/checks/presenter/mod.rs @@ -0,0 +1,269 @@ +//! The live-presentation phase (MULTI-1369): a fourth Kameo actor that surfaces +//! what `multi check` is doing **while it runs**. +//! +//! Unlike the three pipeline actors, [`PresenterActor`] does **not** sit in the +//! `tell` chain between phases. It is display-only: the pipeline actors each hold +//! an `ActorRef` and fire-and-forget a [`UiEvent`] at each +//! milestone (discovered → running → retrying → settled). The presenter folds +//! those into a [`PresenterState`] view-model and renders through a +//! [`RenderBackend`] chosen at spawn from whether stdout is a TTY: +//! +//! * TTY → [`InlineTuiBackend`]: a live requirement→check tree + gauge in an +//! inline viewport, flushing completed requirements into scrollback. +//! * not a TTY → [`HeartbeatBackend`]: a periodic stderr progress line; stdout is +//! left untouched for the reporting actor's byte-for-byte report. +//! * tests → [`NullBackend`] / `RecordingBackend`: no terminal. +//! +//! Events are a concrete enum (exhaustiveness, a single `Message` impl, no +//! per-message boxing); the polymorphism lives on the backend. A separate +//! periodic [`Tick`] drives `backend.tick()` so spinners and elapsed timers keep +//! moving between events — the liveness signal that proves a slow run isn't hung. +//! +//! [`InlineTuiBackend`]: inline::InlineTuiBackend +//! [`HeartbeatBackend`]: heartbeat::HeartbeatBackend +//! [`NullBackend`]: recording::NullBackend + +mod backend; +mod format; +mod heartbeat; +mod inline; +mod recording; +mod state; + +use std::convert::Infallible; +use std::io::IsTerminal; + +use kameo::Actor; +use kameo::actor::{ActorRef, WeakActorRef}; +use kameo::error::ActorStopReason; +use kameo::message::{Context, Message}; + +use crate::checks::model::{CheckId, CheckOutcome}; + +pub(crate) use backend::RenderBackend; +use heartbeat::HeartbeatBackend; +use inline::InlineTuiBackend; +use recording::NullBackend; +use state::PresenterState; + +#[cfg(test)] +use recording::RecordingBackend; + +/// A milestone emitted by a pipeline actor to the presenter. Fire-and-forget via +/// `tell`. A concrete enum so every variant is rendered explicitly (a new variant +/// forces every backend's match to update) with no dynamic dispatch per message. +#[derive(Clone, Debug)] +pub(crate) enum UiEvent { + /// Discovery finished streaming; `total_checks` is the gauge denominator. + DiscoveryComplete { total_checks: usize }, + /// A validated check was enqueued. Carries grouping/labels so the presenter + /// can build the tree without consulting the suite. + CheckQueued { + id: CheckId, + req_index: usize, + req_title: String, + check_title: String, + }, + /// A permit was acquired and the check's agent began running. + CheckStarted { id: CheckId }, + /// A prior attempt finished without a verdict; the check is being re-run. The + /// `attempt` is the just-finished attempt number. + CheckRetrying { id: CheckId, attempt: u32 }, + /// The check reached a terminal verdict. Carries the reconciled outcome so the + /// presenter can render the same record the reporting actor would. + CheckSettled { id: CheckId, outcome: CheckOutcome }, +} + +/// The periodic redraw nudge. Decoupled from [`UiEvent`] so rendering is +/// rate-limited and animations keep moving even when no events arrive. +pub(crate) struct Tick; + +/// The display-only presenter actor. Owns the view-model and the active backend. +pub(crate) struct PresenterActor { + state: PresenterState, + backend: Box, + /// The redraw ticker task, joined on stop so it never outlives the actor. + ticker: Option>, +} + +impl PresenterActor { + /// Build the actor over a chosen backend. + pub(crate) fn new(backend: Box) -> Self { + Self { + state: PresenterState::new(), + backend, + ticker: None, + } + } +} + +impl Actor for PresenterActor { + type Args = Self; + type Error = Infallible; + + async fn on_start( + args: Self::Args, + actor_ref: ActorRef, + ) -> std::result::Result { + // Drive redraws from a background ticker. It holds only a *weak* ref + // between iterations (so it can't keep the actor alive), and races each + // tick against the actor's shutdown so it exits the instant the run ends + // rather than parking until the next (possibly distant) interval. + let interval = args.backend.tick_interval(); + let weak = actor_ref.downgrade(); + let handle = tokio::spawn(async move { + let mut ticker = tokio::time::interval(interval); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + let Some(actor) = weak.upgrade() else { + break; + }; + tokio::select! { + _ = ticker.tick() => { + if actor.tell(Tick).await.is_err() { + break; + } + } + _ = actor.wait_for_shutdown() => break, + } + } + }); + let mut me = args; + me.ticker = Some(handle); + Ok(me) + } + + async fn on_stop( + &mut self, + _actor_ref: WeakActorRef, + _reason: ActorStopReason, + ) -> std::result::Result<(), Self::Error> { + // Stop the ticker and wait for it to unwind before returning, so it never + // outlives the actor (which would read as a leaked task in tests). + if let Some(handle) = self.ticker.take() { + handle.abort(); + let _ = handle.await; + } + // The authoritative restore: runs on graceful stop, kill, *and* panic + // (in addition to the panic hook that shows the cursor). + self.backend.teardown(&self.state); + Ok(()) + } +} + +impl Message for PresenterActor { + type Reply = (); + + async fn handle(&mut self, event: UiEvent, _ctx: &mut Context) -> Self::Reply { + // Mutate the view-model first, then let the backend react to the event. + self.state.apply(&event); + self.backend.apply(&self.state, &event); + } +} + +impl Message for PresenterActor { + type Reply = (); + + async fn handle(&mut self, _tick: Tick, _ctx: &mut Context) -> Self::Reply { + self.backend.tick(&self.state); + } +} + +/// The chosen backend plus whether it **owns the record** — i.e. flushes the +/// per-requirement results to the terminal itself. Only the inline TUI does; when +/// it can't be set up the reporting actor must still write the record to stdout. +pub(crate) struct Backend { + pub backend: Box, + /// True only for the inline TUI: the presenter is the sole terminal writer, so + /// [`crate::checks::run`] must *not* also print the report (it would double). + pub owns_record: bool, +} + +/// Choose the backend for a real run from the terminal environment: the inline +/// TUI when stdout is a TTY (falling back to the heartbeat if terminal setup +/// fails), otherwise the stderr heartbeat. +pub(crate) fn select_backend(color: bool) -> Backend { + let stderr_is_tty = std::io::stderr().is_terminal(); + if std::io::stdout().is_terminal() { + // Only the successfully-initialised inline TUI owns the record; a failed + // setup degrades to the heartbeat, and then stdout still needs the report. + if let Ok(backend) = InlineTuiBackend::new(color) { + return Backend { + backend: Box::new(backend), + owns_record: true, + }; + } + } + Backend { + backend: Box::new(HeartbeatBackend::new(stderr_is_tty)), + owns_record: false, + } +} + +/// A no-op backend: spawned by the pipeline tests (and the future `--quiet`). +pub(crate) fn null_backend() -> Box { + Box::new(NullBackend) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::checks::model::Verdict; + use kameo::actor::Spawn; + use std::sync::{Arc, Mutex}; + + /// End-to-end through the actor: events drive state and reach the backend, and + /// graceful stop runs teardown — exercised via the recording backend. + #[tokio::test] + async fn actor_folds_events_and_records_them() { + let (backend, events) = RecordingBackend::new(); + let presenter = PresenterActor::spawn(PresenterActor::new(Box::new(backend))); + + presenter + .tell(UiEvent::CheckQueued { + id: 0, + req_index: 0, + req_title: "R".into(), + check_title: "c".into(), + }) + .await + .unwrap(); + presenter + .tell(UiEvent::DiscoveryComplete { total_checks: 1 }) + .await + .unwrap(); + presenter + .tell(UiEvent::CheckStarted { id: 0 }) + .await + .unwrap(); + presenter + .tell(UiEvent::CheckSettled { + id: 0, + outcome: CheckOutcome { + title: "c".into(), + verdict: Verdict::Satisfied, + evidence: None, + }, + }) + .await + .unwrap(); + + presenter.stop_gracefully().await.unwrap(); + presenter.wait_for_shutdown().await; + + let recorded: Arc>> = events; + let recorded = recorded.lock().unwrap(); + assert_eq!(recorded.len(), 4); + assert!(matches!(recorded[0], UiEvent::CheckQueued { id: 0, .. })); + assert!(matches!( + recorded[3], + UiEvent::CheckSettled { + id: 0, + outcome: CheckOutcome { + verdict: Verdict::Satisfied, + .. + } + } + )); + } +} diff --git a/src/checks/presenter/recording.rs b/src/checks/presenter/recording.rs new file mode 100644 index 0000000..23ccc32 --- /dev/null +++ b/src/checks/presenter/recording.rs @@ -0,0 +1,62 @@ +//! Headless backends (MULTI-1369). +//! +//! [`NullBackend`] discards everything — the default for the execution/reporting +//! tests that drive the pipeline but don't care about presentation. The future +//! `--quiet` flag will select it too. [`RecordingBackend`] keeps every applied +//! [`UiEvent`] in a shared buffer so a test can assert on the milestone stream +//! without a terminal. + +use std::time::Duration; + +#[cfg(test)] +use std::sync::{Arc, Mutex}; + +use super::UiEvent; +use super::backend::RenderBackend; +use super::state::PresenterState; + +/// A no-op backend: the events flow, nothing is rendered. Spawned for every +/// pipeline test (and, later, for `--quiet`). +pub(crate) struct NullBackend; + +impl RenderBackend for NullBackend { + fn apply(&mut self, _state: &PresenterState, _event: &UiEvent) {} + fn tick(&mut self, _state: &PresenterState) {} + fn teardown(&mut self, _state: &PresenterState) {} + fn tick_interval(&self) -> Duration { + // Long: a no-op backend never needs to wake up to redraw. + Duration::from_secs(3600) + } +} + +/// A backend that records the [`UiEvent`]s it is handed, for test assertions. +#[cfg(test)] +pub(crate) struct RecordingBackend { + events: Arc>>, +} + +#[cfg(test)] +impl RecordingBackend { + /// Build a backend plus the shared handle a test reads the events back from. + pub(crate) fn new() -> (Self, Arc>>) { + let events = Arc::new(Mutex::new(Vec::new())); + ( + Self { + events: events.clone(), + }, + events, + ) + } +} + +#[cfg(test)] +impl RenderBackend for RecordingBackend { + fn apply(&mut self, _state: &PresenterState, event: &UiEvent) { + self.events.lock().unwrap().push(event.clone()); + } + fn tick(&mut self, _state: &PresenterState) {} + fn teardown(&mut self, _state: &PresenterState) {} + fn tick_interval(&self) -> Duration { + Duration::from_secs(3600) + } +} diff --git a/src/checks/presenter/state.rs b/src/checks/presenter/state.rs new file mode 100644 index 0000000..63a9bf5 --- /dev/null +++ b/src/checks/presenter/state.rs @@ -0,0 +1,292 @@ +//! The presenter's view-model: the single source of truth a [`RenderBackend`] +//! renders from (MULTI-1369). +//! +//! [`Message`] mutates this state; [`Message`] then asks the +//! backend to render *from* it. Keeping all derived counts as cheap scans over +//! [`PresenterState::rows`] (recomputed per event, never per frame) sidesteps the +//! increment/decrement bookkeeping bugs an incremental tally would invite — a +//! check that retries moves Running → Retrying → Running again, and a from-scratch +//! scan is always correct regardless of the path taken. +//! +//! [`RenderBackend`]: super::backend::RenderBackend +//! [`Message`]: super::PresenterActor +//! [`Message`]: super::PresenterActor + +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::time::Instant; + +use crate::checks::model::{CheckId, CheckOutcome, RequirementOutcome, Verdict}; + +use super::UiEvent; + +/// Where a single check is in its lifecycle, as seen by the presenter. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum CheckState { + /// Discovered + enqueued, not yet running (no permit acquired). + Queued, + /// An agent is currently running for this check. + Running, + /// A prior attempt finished without a verdict; awaiting its re-run. The + /// `u32` is the just-finished attempt number. + Retrying(u32), + /// Reached a terminal verdict. + Settled(Verdict), +} + +impl CheckState { + fn is_settled(&self) -> bool { + matches!(self, CheckState::Settled(_)) + } +} + +/// One row in the requirement→check tree: a single check's live state. +#[derive(Clone, Debug)] +pub(crate) struct CheckRow { + /// Which requirement (declaration order) this check rolls up into. + pub req_index: usize, + /// The owning requirement's title (for the tree parent + the record). + pub req_title: String, + /// This check's title (the tree leaf label). + pub check_title: String, + /// The check's current lifecycle state. + pub state: CheckState, + /// When the current attempt began running (for the climbing elapsed timer). + pub started: Option, + /// The reconciled outcome, set once settled (carries evidence for the record). + pub outcome: Option, +} + +/// The presenter's whole view-model, mutated by [`PresenterState::apply`]. +/// +/// [`PresenterState::apply`]: PresenterState::apply +pub(crate) struct PresenterState { + /// When the run began (for the total-elapsed header counter). + pub run_started: Instant, + /// Total checks to expect; `None` until `DiscoveryComplete`. + pub total: Option, + /// Whether discovery finished streaming (all rows now exist). + pub discovery_complete: bool, + /// Every check, keyed by id. A `BTreeMap` so iteration is in id order, which + /// is `(req_index, declaration)` order — matching the canonical report. + pub rows: BTreeMap, +} + +impl PresenterState { + /// A fresh state stamped with the run's start instant. + pub(crate) fn new() -> Self { + Self { + run_started: Instant::now(), + total: None, + discovery_complete: false, + rows: BTreeMap::new(), + } + } + + /// Fold one [`UiEvent`] into the view-model. + pub(crate) fn apply(&mut self, event: &UiEvent) { + match event { + UiEvent::DiscoveryComplete { total_checks } => { + self.total = Some(*total_checks); + self.discovery_complete = true; + } + UiEvent::CheckQueued { + id, + req_index, + req_title, + check_title, + } => { + self.rows.insert( + *id, + CheckRow { + req_index: *req_index, + req_title: req_title.clone(), + check_title: check_title.clone(), + state: CheckState::Queued, + started: None, + outcome: None, + }, + ); + } + UiEvent::CheckStarted { id } => { + if let Some(row) = self.rows.get_mut(id) { + row.state = CheckState::Running; + row.started = Some(Instant::now()); + } + } + UiEvent::CheckRetrying { id, attempt } => { + if let Some(row) = self.rows.get_mut(id) { + row.state = CheckState::Retrying(*attempt); + } + } + UiEvent::CheckSettled { id, outcome } => { + if let Some(row) = self.rows.get_mut(id) { + row.state = CheckState::Settled(outcome.verdict); + row.outcome = Some(outcome.clone()); + } + } + } + } + + /// How many checks have settled (the gauge numerator). + pub(crate) fn done(&self) -> usize { + self.rows.values().filter(|r| r.state.is_settled()).count() + } + + /// Per-verdict tally over settled checks: `(satisfied, failed, errored)`. + pub(crate) fn verdict_tallies(&self) -> (usize, usize, usize) { + let mut sat = 0; + let mut failed = 0; + let mut errored = 0; + for row in self.rows.values() { + match row.state { + CheckState::Settled(Verdict::Satisfied) => sat += 1, + CheckState::Settled(Verdict::Failed) => failed += 1, + CheckState::Settled(Verdict::Errored) => errored += 1, + _ => {} + } + } + (sat, failed, errored) + } + + /// How many checks are running right now. + pub(crate) fn running(&self) -> usize { + self.rows + .values() + .filter(|r| matches!(r.state, CheckState::Running)) + .count() + } + + /// How many checks are pending (queued or between retry attempts). + pub(crate) fn pending(&self) -> usize { + self.rows + .values() + .filter(|r| matches!(r.state, CheckState::Queued | CheckState::Retrying(_))) + .count() + } + + /// The set of distinct `req_index`es, in ascending (declaration) order. + pub(crate) fn requirement_indices(&self) -> Vec { + let mut seen = Vec::new(); + for row in self.rows.values() { + if !seen.contains(&row.req_index) { + seen.push(row.req_index); + } + } + seen.sort_unstable(); + seen + } + + /// The rows of one requirement, in id (declaration) order. + pub(crate) fn requirement_rows(&self, req_index: usize) -> Vec<&CheckRow> { + self.rows + .values() + .filter(|r| r.req_index == req_index) + .collect() + } + + /// Whether every check of `req_index` has settled (so the requirement can be + /// flushed to the record). Only meaningful once discovery has completed, since + /// otherwise more checks for this requirement could still arrive. + pub(crate) fn requirement_complete(&self, req_index: usize) -> bool { + let rows = self.requirement_rows(req_index); + !rows.is_empty() && rows.iter().all(|r| r.state.is_settled()) + } + + /// Reconstruct a requirement's [`RequirementOutcome`] from its settled rows, + /// using the **same** AND-aggregation the reporting actor uses — so the + /// presenter's record can never diverge from the canonical verdict. The + /// filepath is irrelevant to rendering and left empty. + pub(crate) fn requirement_outcome(&self, req_index: usize) -> Option { + let rows = self.requirement_rows(req_index); + let first = rows.first()?; + let title = first.req_title.clone(); + let check_outcomes: Vec = + rows.iter().filter_map(|r| r.outcome.clone()).collect(); + Some(RequirementOutcome::aggregate( + title, + PathBuf::new(), + check_outcomes, + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn settled(verdict: Verdict) -> CheckOutcome { + CheckOutcome { + title: "c".into(), + verdict, + evidence: None, + } + } + + #[test] + fn tallies_recompute_from_rows_across_a_retry() { + let mut s = PresenterState::new(); + s.apply(&UiEvent::CheckQueued { + id: 0, + req_index: 0, + req_title: "R".into(), + check_title: "c".into(), + }); + assert_eq!(s.pending(), 1); + assert_eq!(s.running(), 0); + + s.apply(&UiEvent::CheckStarted { id: 0 }); + assert_eq!(s.running(), 1); + assert_eq!(s.pending(), 0); + + // A retry pulls it out of Running and back into the pending tally... + s.apply(&UiEvent::CheckRetrying { id: 0, attempt: 1 }); + assert_eq!(s.running(), 0); + assert_eq!(s.pending(), 1); + + // ...then a fresh start counts it as running again (no double-count). + s.apply(&UiEvent::CheckStarted { id: 0 }); + assert_eq!(s.running(), 1); + + s.apply(&UiEvent::CheckSettled { + id: 0, + outcome: settled(Verdict::Satisfied), + }); + assert_eq!(s.done(), 1); + assert_eq!(s.running(), 0); + assert_eq!(s.verdict_tallies(), (1, 0, 0)); + } + + #[test] + fn requirement_completion_and_outcome_use_shared_aggregation() { + let mut s = PresenterState::new(); + for (id, title) in [(0, "a"), (1, "b")] { + s.apply(&UiEvent::CheckQueued { + id, + req_index: 0, + req_title: "Req".into(), + check_title: title.into(), + }); + } + s.apply(&UiEvent::DiscoveryComplete { total_checks: 2 }); + + // Not complete until both settle. + s.apply(&UiEvent::CheckSettled { + id: 0, + outcome: settled(Verdict::Satisfied), + }); + assert!(!s.requirement_complete(0)); + + s.apply(&UiEvent::CheckSettled { + id: 1, + outcome: settled(Verdict::Failed), + }); + assert!(s.requirement_complete(0)); + + // AND-aggregation: one Failed check fails the requirement. + let outcome = s.requirement_outcome(0).unwrap(); + assert_eq!(outcome.title, "Req"); + assert!(!outcome.satisfied); + assert_eq!(outcome.check_outcomes.len(), 2); + } +} diff --git a/src/checks/reporting.rs b/src/checks/reporting.rs index 558cce6..f4fb049 100644 --- a/src/checks/reporting.rs +++ b/src/checks/reporting.rs @@ -175,6 +175,24 @@ pub fn report(terminal: &Terminal, outcomes: &[RequirementOutcome]) -> Result i32 { + if outcomes.iter().all(|o| o.satisfied) { + 0 + } else { + 1 + } +} + +/// The plain-text requirement line (`[PASS]`/`[FAIL] title`). Exposed so the +/// inline presenter can assert its flushed record matches this exactly. +pub(crate) fn format_requirement_plain(outcome: &RequirementOutcome) -> String { + format_requirement(outcome, false) +} + fn format_requirement(outcome: &RequirementOutcome, color: bool) -> String { if color { let styled = console::style(outcome.title.clone()).bold(); @@ -190,9 +208,16 @@ fn format_requirement(outcome: &RequirementOutcome, color: bool) -> String { } } -fn format_failing_check(check: &CheckOutcome, color: bool) -> String { +/// The plain-text failing-check line (` ✗ title: evidence`). Shared with the +/// inline presenter so the TTY scrollback record and the non-TTY stdout report +/// render a failing check identically. +pub(crate) fn failing_check_text(check: &CheckOutcome) -> String { let evidence = check.evidence.as_deref().unwrap_or("no evidence provided"); - let body = format!(" ✗ {}: {}", check.title, evidence); + format!(" ✗ {}: {}", check.title, evidence) +} + +fn format_failing_check(check: &CheckOutcome, color: bool) -> String { + let body = failing_check_text(check); if color { console::style(body).red().to_string() } else {