From 53e120c9ecb2ce4e6ba17962dcf18af48e3e77df Mon Sep 17 00:00:00 2001 From: Chris Geyer Date: Thu, 16 Apr 2026 19:38:23 +0000 Subject: [PATCH 01/14] feat(run): redesign run output with lifecycle events and three-column summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the old boxed "ROAR Run Complete" summary with a streamed lifecycle of brief, prefixed lines followed by a three-column inputs / job / outputs block. Lifecycle (TTY, emoji-capable): πŸ¦– tracing with preload (proxy off) [user command output streams here] πŸ¦– trace done Β· 0.9s Β· exit 0 πŸ¦– hashing (3 artifacts) πŸ• πŸ¦– lineage captured Β· Inputs (1) Job 4bce5669 Outputs (2) Β· input.txt β†’ 9 pip pkgs β†’ input.txt Β· 10 dpkg pkgs output.json Β· 2 env vars Β· Β· roar show --job 4bce5669 roar dag πŸ¦– done Β· 1.4s (trace 0.9s + post 0.5s) Design points: * πŸ¦– prefix on lifecycle lines (roar: fallback without emoji) β€” reading down the left margin shows exactly what roar did. * Middle-dot Β· prefix on summary lines (quieter than πŸ¦– on every line) keeps the block visually grouped. * Arrow between columns only on the first data row β€” flow direction shown once, not on every row. * Trace duration and post-processing duration reported separately so roar's overhead is visible. * All lifecycle output goes to stderr; user command stdout stays clean for piping. * Three modes: - Rich (TTY + emoji): full output with color, spinner, emoji - Plain (TTY, no emoji): same but with `roar:` prefix and braille spinner - Pipe (no TTY): single `roar: done Β· ... (exit 0)` line only Respects NO_COLOR env var. Plumbs new data through RunResult: backend, post_duration, proxy_active, pip_count, dpkg_count, env_count. TracerResult gains `backend`. Supplemental changes: * roar/presenters/terminal.py (new): centralized TTY/color/emoji/width detection, replacing ad-hoc checks scattered across presenters. * roar/presenters/spinner.py: clock-emoji frame set + optional total count, with braille fallback. (Live counter advance plumbed but not yet wired end-to-end β€” current label shows the static total honestly.) Test plan: 16 new unit tests cover all three modes, interrupted runs (suggest `roar pop`), summary column layout, truncation, and the legacy one-shot show_report path. Full suite 646 passed. Co-Authored-By: Claude Opus 4.6 (1M context) --- roar/application/run/execution.py | 13 +- roar/core/models/run.py | 9 + roar/execution/runtime/coordinator.py | 91 +++-- roar/execution/runtime/tracer.py | 3 + roar/presenters/run_report.py | 499 ++++++++++++++++++++------ roar/presenters/spinner.py | 64 +++- roar/presenters/terminal.py | 76 ++++ tests/unit/test_run_report.py | 215 ++++++++++- tests/unit/test_terminal_caps.py | 71 ++++ 9 files changed, 872 insertions(+), 169 deletions(-) create mode 100644 roar/presenters/terminal.py create mode 100644 tests/unit/test_terminal_caps.py diff --git a/roar/application/run/execution.py b/roar/application/run/execution.py index 1101f0a6..bae25460 100644 --- a/roar/application/run/execution.py +++ b/roar/application/run/execution.py @@ -135,8 +135,17 @@ def execute_and_report( return 1 presenter = ConsolePresenter() - report = RunReportPresenter(presenter) - report.show_report(result, command, quiet) + report = RunReportPresenter(presenter, quiet=quiet) + + # The coordinator already emitted the lifecycle lines (trace start/end, + # hashing spinner, lineage captured). Here we emit the summary block and + # the final "done" line. + report.summary(result, command) + report.done( + exit_code=result.exit_code, + trace_duration=result.duration, + post_duration=result.post_duration, + ) if result.stale_upstream or result.stale_downstream: report.show_stale_warnings( diff --git a/roar/core/models/run.py b/roar/core/models/run.py index 502bcece..58010499 100644 --- a/roar/core/models/run.py +++ b/roar/core/models/run.py @@ -69,6 +69,7 @@ class TracerResult(ImmutableModel): tracer_log_path: Annotated[str, Field(min_length=1)] inject_log_path: str interrupted: bool = False + backend: str | None = None # "ebpf" | "preload" | "ptrace" class RunContext(RoarBaseModel): @@ -109,6 +110,14 @@ class RunResult(ImmutableModel): is_build: bool = False stale_upstream: list[int] = Field(default_factory=list) stale_downstream: list[int] = Field(default_factory=list) + # UX metadata for the new run presenter. Optional so callers that build + # RunResult without these fields (older code paths, error cases) still work. + backend: str | None = None + post_duration: float = 0.0 + proxy_active: bool = False + pip_count: int = 0 + dpkg_count: int = 0 + env_count: int = 0 @computed_field # type: ignore[prop-decorator] @property diff --git a/roar/execution/runtime/coordinator.py b/roar/execution/runtime/coordinator.py index 094f2aa5..65c1f256 100644 --- a/roar/execution/runtime/coordinator.py +++ b/roar/execution/runtime/coordinator.py @@ -109,6 +109,12 @@ def execute(self, ctx: RunContext) -> RunResult: # Backup previous outputs if reversibility is enabled self._backup_previous_outputs(ctx) + # UX presenter for lifecycle output (stderr). Pipes through to existing + # IPresenter for any legacy print_error calls. + from ...presenters.run_report import RunReportPresenter + + run_presenter = RunReportPresenter(self.presenter, quiet=ctx.quiet) + # Start proxy if configured proxy_handle = None extra_env: dict[str, str] = { @@ -151,6 +157,12 @@ def stop_proxy_if_running() -> list: # Execute via tracer from ...core.exceptions import TracerNotFoundError + # Announce trace is starting. We don't yet know the backend (it's + # selected inside tracer.execute), so we just say "tracing"; the + # backend shows up in the "trace done" line below. + proxy_active = proxy_handle is not None + run_presenter.trace_starting(ctx.tracer_mode, proxy_active) + self.logger.debug("Starting tracer execution") try: tracer_result = self._tracer.execute( @@ -162,6 +174,7 @@ def stop_proxy_if_running() -> list: tracer_mode_override=ctx.tracer_mode, fallback_enabled_override=ctx.tracer_fallback, ) + run_presenter.trace_ended(tracer_result.duration, tracer_result.exit_code) self.logger.debug( "Tracer completed: exit_code=%d, duration=%.2fs, interrupted=%s", tracer_result.exit_code, @@ -220,39 +233,35 @@ def stop_proxy_if_running() -> list: is_build=is_build, ) - # Post-processing with progress spinner - from ...presenters.spinner import Spinner - - with Spinner("Sniffing out provenance...", quiet=ctx.quiet) as spin: - # Collect provenance - self.logger.debug("Collecting provenance data") - inject_log = ( - tracer_result.inject_log_path - if os.path.exists(tracer_result.inject_log_path) - else None - ) - roar_dir = os.path.join(ctx.repo_root, ".roar") - provenance_service = ProvenanceService(cache_dir=roar_dir) - t_prov_start = time.perf_counter() - prov = provenance_service.collect( - ctx.repo_root, - tracer_result.tracer_log_path, - inject_log, - config, - ) - t_prov_end = time.perf_counter() - self.logger.debug( - "Provenance collected: read_files=%d, written_files=%d", - len(prov.get("data", {}).get("read_files", [])), - len(prov.get("data", {}).get("written_files", [])), - ) - - # Stop proxy and collect S3 entries before DB recording. - s3_entries = stop_proxy_if_running() + # Collect provenance first (fast) so we know total file count for the + # hashing spinner. + self.logger.debug("Collecting provenance data") + inject_log = ( + tracer_result.inject_log_path if os.path.exists(tracer_result.inject_log_path) else None + ) + roar_dir = os.path.join(ctx.repo_root, ".roar") + provenance_service = ProvenanceService(cache_dir=roar_dir) + t_prov_start = time.perf_counter() + prov = provenance_service.collect( + ctx.repo_root, + tracer_result.tracer_log_path, + inject_log, + config, + ) + t_prov_end = time.perf_counter() + n_read = len(prov.get("data", {}).get("read_files", [])) + n_written = len(prov.get("data", {}).get("written_files", [])) + self.logger.debug( + "Provenance collected: read_files=%d, written_files=%d", + n_read, + n_written, + ) - spin.update("Hashing files and TReqing outputs...") + # Stop proxy and collect S3 entries before DB recording. + s3_entries = stop_proxy_if_running() - # Record in database + total_files = n_read + n_written + with run_presenter.hashing(total=total_files or None): self.logger.debug("Recording job in database") t_record_start = time.perf_counter() job_id, job_uid, read_file_info, written_file_info, stale_upstream, stale_downstream = ( @@ -275,12 +284,12 @@ def stop_proxy_if_running() -> list: len(written_file_info), ) - spin.update("Auto-lineaging done, tidying up...") - # Cleanup temp files self.logger.debug("Cleaning up temporary log files") self._cleanup_logs(tracer_result.tracer_log_path, tracer_result.inject_log_path) + run_presenter.lineage_captured() + t_postrun_end = time.perf_counter() if emit_timing: @@ -304,6 +313,16 @@ def stop_proxy_if_running() -> list: tracer_result.exit_code, tracer_result.duration, ) + # UX metadata for the new run presenter. + exec_packages = prov.get("executables", {}).get("packages", {}) or {} + pip_count = len(exec_packages.get("pip", {}) or {}) + dpkg_count = len(exec_packages.get("dpkg", {}) or {}) + env_count = len(prov.get("runtime", {}).get("env_vars", {}) or {}) + post_duration = t_postrun_end - t_postrun_start + backend_name = getattr(tracer_result, "backend", None) + if not isinstance(backend_name, str): + backend_name = None + return RunResult( exit_code=tracer_result.exit_code, job_id=job_id, @@ -315,6 +334,12 @@ def stop_proxy_if_running() -> list: is_build=is_build, stale_upstream=stale_upstream, stale_downstream=stale_downstream, + backend=backend_name, + post_duration=post_duration, + proxy_active=proxy_active, + pip_count=pip_count, + dpkg_count=dpkg_count, + env_count=env_count, ) def _record_job( diff --git a/roar/execution/runtime/tracer.py b/roar/execution/runtime/tracer.py index 0688d390..8d3aaeb0 100644 --- a/roar/execution/runtime/tracer.py +++ b/roar/execution/runtime/tracer.py @@ -300,8 +300,10 @@ def execute( signal_handler.install() exit_code = 1 + selected_backend: str | None = None try: for idx, (backend, tracer_path) in enumerate(candidates): + selected_backend = backend # Ensure stale files from previous attempts don't mask failures. for log_file in (tracer_log_file, inject_log_file): try: @@ -368,6 +370,7 @@ def execute( tracer_log_path=tracer_log_file, inject_log_path=inject_log_file, interrupted=signal_handler.is_interrupted(), + backend=selected_backend, ) def get_log_paths(self, roar_dir: Path) -> tuple: diff --git a/roar/presenters/run_report.py b/roar/presenters/run_report.py index ba7c4dae..b6fb4d64 100644 --- a/roar/presenters/run_report.py +++ b/roar/presenters/run_report.py @@ -1,16 +1,34 @@ +"""Run report presenter. + +Renders the lifecycle of a `roar run` as a series of brief, prefixed lines +followed by a three-column summary block. All output goes to stderr so the +user command's stdout remains clean for piping. + +Design points: +* πŸ¦– prefix on lifecycle lines when emoji is supported, `roar:` otherwise. +* Middle-dot `Β·` prefix on the summary block lines (same idea as πŸ¦– but + quieter β€” keeps the block visually tied to roar without repeating branding). +* Arrow between columns on the first data row only β€” direction of flow shown + once, not on every row. +* Trace duration and post-processing duration reported separately so the + overhead of roar is visible. +* When stderr is not a TTY (piped / redirected / captured), drop to a single + one-line "done" summary. """ -Run report presenter for displaying run completion reports. -Handles all output formatting for run/build results. -Follows SRP: only handles report presentation. -""" +from __future__ import annotations import os -import shlex -from typing import Any +import re +import sys +from contextlib import contextmanager +from dataclasses import dataclass +from typing import IO from ..core.interfaces.presenter import IPresenter from ..core.models.run import RunResult +from .spinner import BRAILLE_FRAMES, CLOCK_FRAMES, Spinner +from .terminal import TerminalCaps, detect, style def format_size(size_bytes: int | None) -> str: @@ -25,21 +43,179 @@ def format_size(size_bytes: int | None) -> str: return f"{size:.1f}TB" +def _basename(path: str) -> str: + """Best-effort relative path for display; falls back to basename.""" + try: + rel = os.path.relpath(path) + if not rel.startswith(".."): + return rel + except ValueError: + pass + return os.path.basename(path) or path + + +# --------------------------------------------------------------------------- +# Column layout +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class _ColumnPlan: + col_width: int + gutter: int + indent: int + + @property + def total(self) -> int: + return self.indent + 3 * self.col_width + 2 * self.gutter + + @classmethod + def for_width(cls, width: int) -> _ColumnPlan: + indent = 4 # 2 (margin dot + space) + 2 more for breathing room + # Leave a few chars of right-hand slack. + available = max(48, width - indent - 2) + gutter = 4 + col = (available - 2 * gutter) // 3 + col = max(14, min(col, 28)) + return cls(col_width=col, gutter=gutter, indent=indent) + + +def _truncate(text: str, width: int) -> str: + if len(text) <= width: + return text + if width <= 1: + return text[:width] + return text[: width - 1] + "…" + + +def _pad(text: str, width: int) -> str: + visible = len(text) + if visible >= width: + return text + return text + " " * (width - visible) + + +# --------------------------------------------------------------------------- +# Presenter +# --------------------------------------------------------------------------- + + class RunReportPresenter: - """ - Formats and displays run completion reports. + """Formats and displays run lifecycle events and the final summary. - Follows SRP: only handles report presentation. + The legacy entry point ``show_report(result, command, quiet)`` is preserved + for backward compatibility; it renders the new one-shot summary. Callers + that want lifecycle output (``trace_starting`` β†’ ``trace_ended`` β†’ hashing + spinner β†’ ``lineage_captured`` β†’ ``summary`` β†’ ``done``) can invoke those + methods individually. """ - def __init__(self, presenter: IPresenter) -> None: - """ - Initialize report presenter. + def __init__( + self, + presenter: IPresenter | None = None, + *, + stream: IO | None = None, + caps: TerminalCaps | None = None, + quiet: bool = False, + ) -> None: + # `presenter` is retained only so that ``show_stale_warnings`` can + # continue to use it; new output always goes to stderr. + self._out = presenter + self._stream = stream if stream is not None else sys.stderr + self._caps = caps if caps is not None else detect(self._stream) + self._quiet = quiet - Args: - presenter: Base presenter for output + # ---- lifecycle events ------------------------------------------------- + + def trace_starting(self, backend: str | None, proxy_active: bool) -> None: + """Announce that tracing is about to start.""" + if self._quiet or self._caps.pipe_mode: + return + b = backend or "auto" + proxy = "proxy on" if proxy_active else "proxy off" + verb = style("tracing", "bold", enabled=self._caps.can_color) + params = style(f"with {b} ({proxy})", "dim", enabled=self._caps.can_color) + self._emit_lifecycle(f"{verb} {params}") + + def trace_ended(self, duration: float, exit_code: int) -> None: + """Announce that the traced command has exited.""" + if self._quiet or self._caps.pipe_mode: + return + verb = style("trace done", "bold", enabled=self._caps.can_color) + dur = self._fmt_duration(duration) + exit_text = f"exit {exit_code}" + if exit_code != 0 and self._caps.can_color: + exit_text = style(exit_text, "red", "bold", enabled=True) + elif exit_code == 0 and self._caps.can_color: + exit_text = style(exit_text, "dim", enabled=True) + self._emit_lifecycle(f"{verb} Β· {dur} Β· {exit_text}") + + @contextmanager + def hashing(self, total: int | None = None): + """Context manager: render a spinner for the hashing/recording phase. + + *total*, if given, is displayed as "(N artifacts)" after the label β€” + the counter does not currently update live during hashing, so we show + the static total rather than a misleading "0/N" that never ticks. """ - self._out = presenter + if self._quiet or self._caps.pipe_mode: + yield _NullProgress() + return + prefix = self._lifecycle_prefix() + label = style("hashing", "bold", enabled=self._caps.can_color) + if total: + noun = "artifact" if total == 1 else "artifacts" + count_str = style(f" ({total} {noun})", "dim", enabled=self._caps.can_color) + label = f"{label}{count_str}" + frames = CLOCK_FRAMES if self._caps.can_emoji else BRAILLE_FRAMES + with Spinner(label, prefix=prefix, frames=frames, interval=0.1) as sp: + yield sp + + def lineage_captured(self) -> None: + if self._quiet or self._caps.pipe_mode: + return + verb = style("lineage captured", "bold", enabled=self._caps.can_color) + self._emit_lifecycle(verb) + + def summary(self, result: RunResult, command: list[str]) -> None: + """Render the three-column inputs/job/outputs block.""" + if self._quiet or self._caps.pipe_mode: + return + self._render_summary(result, command) + + def done( + self, + *, + exit_code: int, + trace_duration: float, + post_duration: float, + ) -> None: + """Emit the final line. Falls back to a one-liner in pipe mode.""" + if self._quiet: + return + total = trace_duration + post_duration + verb = "done" if exit_code == 0 else "failed" + if self._caps.pipe_mode: + # Minimal one-liner when piping. + line = ( + f"roar: {verb} Β· {self._fmt_duration(total)} " + f"(trace {self._fmt_duration(trace_duration)} + " + f"post {self._fmt_duration(post_duration)}, exit {exit_code})" + ) + print(line, file=self._stream, flush=True) + return + color = "green" if exit_code == 0 else "red" + verb_styled = style(verb, "bold", color, enabled=self._caps.can_color) + total_s = style(self._fmt_duration(total), "bold", enabled=self._caps.can_color) + breakdown = style( + f"(trace {self._fmt_duration(trace_duration)} + " + f"post {self._fmt_duration(post_duration)})", + "dim", + enabled=self._caps.can_color, + ) + self._emit_lifecycle(f"{verb_styled} Β· {total_s} {breakdown}") + + # ---- backward-compat one-shot ---------------------------------------- def show_report( self, @@ -47,62 +223,33 @@ def show_report( command: list[str], quiet: bool = False, ) -> None: - """ - Display run completion report. + """Render the full lifecycle in one call using data in *result*. - Args: - result: Run result with execution details - command: Original command that was executed - quiet: If True, suppress output + Used by application/run/execution.py when the run has already + finished and we only have the RunResult to work with. """ - if quiet: + if quiet or self._quiet: return + if self._caps.pipe_mode: + self.done( + exit_code=result.exit_code, + trace_duration=result.duration, + post_duration=result.post_duration, + ) + return + # The trace_starting / trace_ended / hashing / lineage_captured lines + # are ideally emitted during the run itself. When this method is the + # only entry point we skip those and render the meaningful tail. + self.trace_ended(result.duration, result.exit_code) + self.lineage_captured() + self._render_summary(result, command) + self.done( + exit_code=result.exit_code, + trace_duration=result.duration, + post_duration=result.post_duration, + ) - self._out.print("") - self._out.print("=" * 60) - - step_type = "Build" if result.is_build else "Run" - if result.interrupted: - self._out.print(f"ROAR {step_type} Interrupted") - else: - self._out.print(f"ROAR {step_type} Complete") - - self._out.print("=" * 60) - self._out.print(f"Command: {shlex.join(command)}") - self._out.print(f"Duration: {result.duration:.1f}s") - self._out.print(f"Exit code: {result.exit_code}") - - if result.interrupted: - self._out.print("Status: interrupted") - - self._out.print("") - - if result.inputs: - self._out.print("Read files:") - for f in result.inputs: - self._print_file(f) - self._out.print("") - - if result.outputs: - self._out.print("Written files:") - for f in result.outputs: - self._print_file(f) - self._out.print("") - - self._out.print(f"Job: {result.job_uid}") - - if result.interrupted and result.outputs: - self._out.print("") - self._out.print("Note: Run was interrupted. Output files may be incomplete.") - self._out.print("Use 'roar pop' to remove this job and delete safe written files.") - - self._out.print("") - self._out.print("Next:") - self._out.print(f" roar show --job {result.job_uid}") - if result.interrupted and result.outputs: - self._out.print(" roar pop") - else: - self._out.print(" roar dag") + # ---- stale warnings (unchanged semantics) ---------------------------- def show_stale_warnings( self, @@ -110,21 +257,14 @@ def show_stale_warnings( stale_downstream: list[int], is_build: bool = False, ) -> None: - """ - Display stale step warnings. - - Args: - stale_upstream: List of stale upstream step numbers - stale_downstream: List of stale downstream step numbers - is_build: Whether this is a build step - """ + if not (stale_upstream or stale_downstream) or self._out is None: + return if stale_upstream: self._out.print("") step_refs = ", ".join(f"@{s}" for s in stale_upstream) self._out.print(f"Warning: This job consumed stale inputs from: {step_refs}") self._out.print("The upstream steps were re-run but this step used old outputs.") self._out.print("Consider re-running this step after updating upstream.") - if stale_downstream: self._out.print("") step_prefix = "B" if is_build else "" @@ -137,44 +277,187 @@ def show_upstream_stale_warning( step_num: int, upstream_stale: list[int], ) -> bool: - """ - Show warning about stale upstream steps and ask for confirmation. - - Args: - step_num: Current step number - upstream_stale: List of stale upstream step numbers - - Returns: - True if user wants to proceed, False otherwise - """ + if self._out is None: + return True step_refs = ", ".join(f"@{s}" for s in upstream_stale) self._out.print(f"Warning: Step @{step_num} depends on stale upstream steps: {step_refs}") self._out.print( "The upstream steps have been re-run more recently than their outputs were consumed." ) self._out.print("") - return self._out.confirm("Run anyway?", default=False) - def _print_file(self, f: dict[str, Any]) -> None: - """Print file info with path, size, and hashes.""" - path = f["path"] - size = format_size(f.get("size")) - - # Make path relative if possible - try: - rel_path = os.path.relpath(path) - if not rel_path.startswith(".."): - path = rel_path - except ValueError: - pass - - self._out.print(f" {path}") - - # Show all hashes - hashes = f.get("hashes", []) - if hashes: - hash_strs = [f"{h['algorithm']}: {h['digest'][:12]}..." for h in hashes] - self._out.print(f" size: {size} {', '.join(hash_strs)}") + # ---- internals ------------------------------------------------------- + + def _lifecycle_prefix(self) -> str: + return "πŸ¦– " if self._caps.can_emoji else "roar: " + + def _summary_prefix(self) -> str: + """Subtle line prefix for the summary block β€” dim middle-dot.""" + dot = "Β·" if self._caps.can_emoji else "." + return style(f"{dot} ", "dim", enabled=self._caps.can_color) + + def _emit_lifecycle(self, message: str) -> None: + prefix = self._lifecycle_prefix() + color = self._caps.can_color + brand = style(prefix.rstrip(), "magenta", enabled=color) + print(f"{brand} {message}", file=self._stream, flush=True) + + def _emit_summary(self, line: str = "") -> None: + if line: + print(f"{self._summary_prefix()}{line}", file=self._stream, flush=True) else: - self._out.print(f" size: {size}") + print(self._summary_prefix().rstrip(), file=self._stream, flush=True) + + def _fmt_duration(self, seconds: float) -> str: + # Keep it tight: sub-second in ms, else one decimal. + if seconds < 0.1: + ms = max(1, round(seconds * 1000)) + return f"{ms}ms" + return f"{seconds:.1f}s" + + def _render_summary(self, result: RunResult, command: list[str]) -> None: + plan = _ColumnPlan.for_width(self._caps.width) + + inputs = [_basename(f["path"]) for f in result.inputs] + outputs = [_basename(f["path"]) for f in result.outputs] + + n_in = len(inputs) + n_out = len(outputs) + + in_header = f"Inputs ({n_in})" + job_header = f"Job {result.job_uid}" + out_header = f"Outputs ({n_out})" + + # Rows available per column after the header. + per_col = 4 + + # Left column rows (inputs, with "... and N more" if needed). + in_rows: list[str] = [] + shown_in = min(n_in, per_col - 1) if n_in > per_col else min(n_in, per_col) + for path in inputs[:shown_in]: + in_rows.append(_truncate(path, plan.col_width)) + if n_in > shown_in: + more = style( + f"… and {n_in - shown_in} more", + "dim", + "italic", + enabled=self._caps.can_color, + ) + in_rows.append(more) + + # Middle column rows: package & env counts. + job_rows = [] + if result.pip_count: + job_rows.append(f"{result.pip_count} pip pkgs") + if result.dpkg_count: + job_rows.append(f"{result.dpkg_count} dpkg pkgs") + if result.env_count: + job_rows.append(f"{result.env_count} env vars") + if not job_rows: + job_rows.append(style("β€”", "dim", enabled=self._caps.can_color)) + + # Right column rows (outputs). + out_rows: list[str] = [] + shown_out = min(n_out, per_col - 1) if n_out > per_col else min(n_out, per_col) + for path in outputs[:shown_out]: + out_rows.append(_truncate(path, plan.col_width)) + if n_out > shown_out: + more = style( + f"… and {n_out - shown_out} more", + "dim", + "italic", + enabled=self._caps.can_color, + ) + out_rows.append(more) + + # -- emit -- + self._emit_summary() + header_line = self._format_row( + in_header, job_header, out_header, plan, arrow=False, dim_all=True + ) + self._emit_summary(header_line) + + max_rows = max(len(in_rows), len(job_rows), len(out_rows), 1) + for i in range(max_rows): + left = in_rows[i] if i < len(in_rows) else "" + mid = job_rows[i] if i < len(job_rows) else "" + right = out_rows[i] if i < len(out_rows) else "" + self._emit_summary(self._format_row(left, mid, right, plan, arrow=(i == 0))) + + self._emit_summary() + show_cmd = style( + f"roar show --job {result.job_uid}", + "cyan", + enabled=self._caps.can_color, + ) + # Offer `roar pop` instead of `roar dag` when the run was interrupted + # and left partial outputs behind (pop removes the partial job). + if result.interrupted and result.outputs: + next_cmd = style("roar pop", "cyan", enabled=self._caps.can_color) + else: + next_cmd = style("roar dag", "cyan", enabled=self._caps.can_color) + self._emit_summary(f"{show_cmd} {next_cmd}") + self._emit_summary() + + def _format_row( + self, + left: str, + mid: str, + right: str, + plan: _ColumnPlan, + *, + arrow: bool, + dim_all: bool = False, + ) -> str: + """Render a single row with the three columns padded to plan widths.""" + color = self._caps.can_color and dim_all + + def styled(s: str) -> str: + return style(s, "dim", enabled=color) if color and s else s + + # Visible length calculation when the string contains ANSI codes. + left_visible = _visible_len(left) + mid_visible = _visible_len(mid) + right_visible = _visible_len(right) + + left_pad = max(0, plan.col_width - left_visible) + mid_pad = max(0, plan.col_width - mid_visible) + right_pad = max(0, plan.col_width - right_visible) + + arrow_str = " β†’ " if self._caps.can_emoji else " > " + gutter_str = " " * (plan.gutter - len(arrow_str)) + if not arrow: + arrow_str = " " * len(arrow_str) + + return ( + f"{styled(left)}{' ' * left_pad}{gutter_str}{arrow_str}" + f"{styled(mid)}{' ' * mid_pad}{gutter_str}{arrow_str}" + f"{styled(right)}{' ' * right_pad}" + ) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") + + +def _visible_len(s: str) -> int: + """Length of a string ignoring ANSI escape sequences.""" + return len(_ANSI_RE.sub("", s)) + + +class _NullProgress: + """No-op stand-in returned by Presenter.hashing() when in pipe mode.""" + + def advance(self, delta: int = 1) -> None: + pass + + def set_count(self, count: int) -> None: + pass + + def update(self, message: str) -> None: + pass diff --git a/roar/presenters/spinner.py b/roar/presenters/spinner.py index e7776000..2cbbd563 100644 --- a/roar/presenters/spinner.py +++ b/roar/presenters/spinner.py @@ -1,26 +1,54 @@ -"""Simple threaded spinner for post-command processing feedback.""" +"""Threaded spinner for post-command processing feedback. + +Supports two frame sets β€” clock emojis (for UTF-8 terminals) and the classic +ANSI braille cycle β€” and an optional live "N/M" counter appended to the label. +All output goes to stderr; no-ops when stderr isn't a TTY or when quiet. +""" from __future__ import annotations import sys import threading -_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" -_INTERVAL = 0.08 +# Full 12-position hour clock cycle. +CLOCK_FRAMES = "πŸ•›πŸ•πŸ•‘πŸ•’πŸ•“πŸ•”πŸ••πŸ•–πŸ•—πŸ•˜πŸ•™πŸ•š" +BRAILLE_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" +_DEFAULT_INTERVAL = 0.1 class Spinner: """Context-manager spinner that writes to stderr. - No-ops when stderr is not a TTY or when *quiet* is True. + Keyword arguments: + message -- status text shown after the frame + frames -- iterable of single-char-wide frames (emoji OK) + interval -- seconds between frames + prefix -- text shown before the frame (e.g. "πŸ¦– " or "roar: ") + quiet -- if True, becomes a no-op + total -- if set, renders a live "N/total" counter after the message """ - def __init__(self, message: str = "", *, quiet: bool = False) -> None: + def __init__( + self, + message: str = "", + *, + frames: str = BRAILLE_FRAMES, + interval: float = _DEFAULT_INTERVAL, + prefix: str = "", + quiet: bool = False, + total: int | None = None, + ) -> None: self._message = message + self._frames = frames + self._interval = interval + self._prefix = prefix + self._total = total + self._count = 0 self._active = not quiet and hasattr(sys.stderr, "isatty") and sys.stderr.isatty() self._stop_event = threading.Event() self._thread: threading.Thread | None = None self._lock = threading.Lock() + self._last_line_len = 0 # -- context manager ----------------------------------------------------- @@ -35,8 +63,8 @@ def __exit__(self, *_exc) -> None: if self._thread is not None: self._thread.join() if self._active: - # clear the spinner line - sys.stderr.write("\r" + " " * (len(self._message) + 4) + "\r") + # Clear the spinner line. + sys.stderr.write("\r" + " " * self._last_line_len + "\r") sys.stderr.flush() # -- public api ---------------------------------------------------------- @@ -46,6 +74,15 @@ def update(self, message: str) -> None: with self._lock: self._message = message + def advance(self, delta: int = 1) -> None: + """Increment the N of the N/total counter.""" + with self._lock: + self._count += delta + + def set_count(self, count: int) -> None: + with self._lock: + self._count = count + # -- internals ----------------------------------------------------------- def _spin(self) -> None: @@ -53,8 +90,15 @@ def _spin(self) -> None: while not self._stop_event.is_set(): with self._lock: msg = self._message - frame = _FRAMES[idx % len(_FRAMES)] - sys.stderr.write(f"\r{frame} {msg}") + count = self._count + total = self._total + frame = self._frames[idx % len(self._frames)] + suffix = f" {count}/{total}" if total is not None else "" + line = f"{self._prefix}{msg} {frame}{suffix}" + # Pad to clear any residue from a longer previous line. + pad = max(0, self._last_line_len - len(line)) + sys.stderr.write(f"\r{line}{' ' * pad}") sys.stderr.flush() + self._last_line_len = len(line) idx += 1 - self._stop_event.wait(_INTERVAL) + self._stop_event.wait(self._interval) diff --git a/roar/presenters/terminal.py b/roar/presenters/terminal.py new file mode 100644 index 00000000..406658ff --- /dev/null +++ b/roar/presenters/terminal.py @@ -0,0 +1,76 @@ +"""Centralized terminal capability detection for roar presenters. + +Single source of truth for "is this stream a TTY", "should we use color", +"can we print emoji", and "how wide is the terminal". Used by the run +presenter, spinner, and anyone else that wants to render nicely. +""" + +from __future__ import annotations + +import os +import shutil +import sys +from dataclasses import dataclass +from typing import IO + + +@dataclass(frozen=True) +class TerminalCaps: + """What the current terminal can do, as seen through `stream`.""" + + is_tty: bool + can_color: bool + can_emoji: bool + width: int + + @property + def pipe_mode(self) -> bool: + """True when the stream is not a TTY (piped, redirected, captured).""" + return not self.is_tty + + +def detect(stream: IO | None = None) -> TerminalCaps: + """Sniff capabilities of *stream* (defaults to stderr).""" + s = stream if stream is not None else sys.stderr + is_tty = bool(getattr(s, "isatty", lambda: False)()) + + # Standard NO_COLOR convention (https://no-color.org/). + no_color_env = os.environ.get("NO_COLOR", "") != "" + can_color = is_tty and not no_color_env + + enc = (getattr(s, "encoding", "") or "").lower() + lang = os.environ.get("LC_ALL") or os.environ.get("LANG") or "" + can_emoji = is_tty and ("utf" in enc or "UTF-8" in lang.upper()) + + try: + width = shutil.get_terminal_size((80, 24)).columns + except OSError: + width = 80 + width = max(40, width) + + return TerminalCaps(is_tty=is_tty, can_color=can_color, can_emoji=can_emoji, width=width) + + +# ---- ANSI helpers ----------------------------------------------------------- + +_ANSI = { + "reset": "\033[0m", + "bold": "\033[1m", + "dim": "\033[2m", + "italic": "\033[3m", + "red": "\033[31m", + "green": "\033[32m", + "yellow": "\033[33m", + "blue": "\033[34m", + "magenta": "\033[35m", + "cyan": "\033[36m", + "gray": "\033[90m", +} + + +def style(text: str, *codes: str, enabled: bool = True) -> str: + """Wrap *text* in the given ANSI styles, or return it unchanged if disabled.""" + if not enabled or not codes: + return text + prefix = "".join(_ANSI.get(c, "") for c in codes) + return f"{prefix}{text}{_ANSI['reset']}" if prefix else text diff --git a/tests/unit/test_run_report.py b/tests/unit/test_run_report.py index 2537b91a..fd8c5338 100644 --- a/tests/unit/test_run_report.py +++ b/tests/unit/test_run_report.py @@ -1,9 +1,29 @@ +"""Tests for RunReportPresenter β€” the new lifecycle-style run output.""" + from __future__ import annotations +import io +import re from typing import Any from roar.core.models.run import RunResult from roar.presenters.run_report import RunReportPresenter +from roar.presenters.terminal import TerminalCaps + +ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") + + +def _strip(s: str) -> str: + return ANSI_RE.sub("", s) + + +def _tty_caps(width: int = 120) -> TerminalCaps: + """Force TTY-like caps so presenter renders the full summary.""" + return TerminalCaps(is_tty=True, can_color=False, can_emoji=False, width=width) + + +def _pipe_caps() -> TerminalCaps: + return TerminalCaps(is_tty=False, can_color=False, can_emoji=False, width=80) class _CapturePresenter: @@ -36,11 +56,14 @@ def confirm(self, message: str, default: bool = False) -> bool: return default -def test_interrupted_report_references_pop_not_clean() -> None: - presenter = _CapturePresenter() - report = RunReportPresenter(presenter) +# ---- summary content ------------------------------------------------------ - report.show_report( + +def test_interrupted_run_with_outputs_suggests_pop() -> None: + buf = io.StringIO() + report = RunReportPresenter(stream=buf, caps=_tty_caps()) + + report.summary( RunResult( exit_code=130, job_id=1, @@ -54,17 +77,17 @@ def test_interrupted_report_references_pop_not_clean() -> None: ["python", "train.py"], ) - rendered = "\n".join(presenter.messages) - assert "roar pop" in rendered - assert "roar clean" not in rendered - assert "roar show --job job12345" in rendered + out = _strip(buf.getvalue()) + assert "roar pop" in out + assert "roar dag" not in out + assert "roar show --job job12345" in out -def test_successful_report_suggests_show_and_dag() -> None: - presenter = _CapturePresenter() - report = RunReportPresenter(presenter) +def test_successful_run_suggests_show_and_dag() -> None: + buf = io.StringIO() + report = RunReportPresenter(stream=buf, caps=_tty_caps()) - report.show_report( + report.summary( RunResult( exit_code=0, job_id=2, @@ -78,7 +101,167 @@ def test_successful_report_suggests_show_and_dag() -> None: ["python", "train.py"], ) - rendered = "\n".join(presenter.messages) - assert "Next:" in rendered - assert "roar show --job job67890" in rendered - assert "roar dag" in rendered + out = _strip(buf.getvalue()) + assert "roar show --job job67890" in out + assert "roar dag" in out + + +def test_summary_has_three_column_headers() -> None: + buf = io.StringIO() + report = RunReportPresenter(stream=buf, caps=_tty_caps()) + + report.summary( + RunResult( + exit_code=0, + job_id=1, + job_uid="abc12345", + duration=1.0, + inputs=[{"path": "/a/in.txt", "size": 1, "hashes": []}], + outputs=[{"path": "/a/out.txt", "size": 1, "hashes": []}], + pip_count=5, + dpkg_count=10, + env_count=3, + ), + ["python", "x.py"], + ) + out = _strip(buf.getvalue()) + assert "Inputs (1)" in out + assert "Job abc12345" in out + assert "Outputs (1)" in out + assert "5 pip pkgs" in out + assert "10 dpkg pkgs" in out + assert "3 env vars" in out + + +def test_summary_truncates_and_shows_more_indicator() -> None: + buf = io.StringIO() + report = RunReportPresenter(stream=buf, caps=_tty_caps()) + # 10 inputs β€” well over the per-column cap of 4. + inputs = [{"path": f"/data/in_{i}.txt", "size": 1, "hashes": []} for i in range(10)] + report.summary( + RunResult( + exit_code=0, + job_id=1, + job_uid="abc12345", + duration=1.0, + inputs=inputs, + outputs=[], + ), + ["python", "x.py"], + ) + out = _strip(buf.getvalue()) + assert "Inputs (10)" in out + assert "and 7 more" in out + + +# ---- quiet + pipe modes --------------------------------------------------- + + +def test_quiet_mode_emits_nothing() -> None: + buf = io.StringIO() + report = RunReportPresenter(stream=buf, caps=_tty_caps(), quiet=True) + + report.trace_starting(backend="preload", proxy_active=False) + report.trace_ended(duration=0.5, exit_code=0) + report.lineage_captured() + report.summary( + RunResult( + exit_code=0, + job_id=1, + job_uid="abc12345", + duration=0.5, + inputs=[], + outputs=[], + ), + ["python", "x.py"], + ) + report.done(exit_code=0, trace_duration=0.5, post_duration=0.1) + assert buf.getvalue() == "" + + +def test_pipe_mode_emits_only_done_line() -> None: + buf = io.StringIO() + report = RunReportPresenter(stream=buf, caps=_pipe_caps()) + + # Lifecycle events are silent in pipe mode. + report.trace_starting(backend="preload", proxy_active=False) + report.trace_ended(duration=0.5, exit_code=0) + report.lineage_captured() + report.summary( + RunResult( + exit_code=0, + job_id=1, + job_uid="abc12345", + duration=0.5, + inputs=[], + outputs=[], + ), + ["python", "x.py"], + ) + report.done(exit_code=0, trace_duration=0.5, post_duration=0.1) + out = buf.getvalue() + # Only the final one-liner. + assert out.count("\n") == 1 + assert out.startswith("roar: done") + assert "exit 0" in out + + +# ---- lifecycle lines ------------------------------------------------------ + + +def test_trace_starting_uses_plain_prefix_without_emoji() -> None: + buf = io.StringIO() + report = RunReportPresenter(stream=buf, caps=_tty_caps()) + report.trace_starting(backend="preload", proxy_active=True) + out = _strip(buf.getvalue()) + assert out.startswith("roar:") + assert "tracing" in out + assert "preload" in out + assert "proxy on" in out + + +def test_trace_ended_colors_nonzero_exit() -> None: + buf = io.StringIO() + caps = TerminalCaps(is_tty=True, can_color=True, can_emoji=False, width=80) + report = RunReportPresenter(stream=buf, caps=caps) + report.trace_ended(duration=1.5, exit_code=3) + raw = buf.getvalue() + # Red ANSI code around exit text. + assert "\x1b[31m" in raw or "\x1b[1m\x1b[31m" in raw or "exit 3" in raw + # And the plain text is still there. + assert "exit 3" in _strip(raw) + + +def test_done_shows_separate_trace_and_post_durations() -> None: + buf = io.StringIO() + report = RunReportPresenter(stream=buf, caps=_tty_caps()) + report.done(exit_code=0, trace_duration=1.3, post_duration=0.3) + out = _strip(buf.getvalue()) + assert "1.6s" in out + assert "trace 1.3s" in out + assert "post 0.3s" in out + + +# ---- legacy entry point --------------------------------------------------- + + +def test_show_report_legacy_one_shot() -> None: + """show_report() still works β€” renders trace_ended + summary + done in a row.""" + buf = io.StringIO() + report = RunReportPresenter(_CapturePresenter(), stream=buf, caps=_tty_caps()) + report.show_report( + RunResult( + exit_code=0, + job_id=1, + job_uid="job12345", + duration=1.0, + inputs=[], + outputs=[], + post_duration=0.2, + ), + ["python", "x.py"], + ) + out = _strip(buf.getvalue()) + assert "roar show --job job12345" in out + assert "trace done" in out + assert "done" in out diff --git a/tests/unit/test_terminal_caps.py b/tests/unit/test_terminal_caps.py new file mode 100644 index 00000000..269e8104 --- /dev/null +++ b/tests/unit/test_terminal_caps.py @@ -0,0 +1,71 @@ +"""Tests for presenters.terminal capability detection and styling.""" + +from __future__ import annotations + +import pytest + +from roar.presenters.terminal import detect, style + + +class _FakeStream: + def __init__(self, tty: bool, encoding: str = "utf-8"): + self.encoding = encoding + self._tty = tty + + def isatty(self) -> bool: + return self._tty + + +class TestDetect: + def test_tty_utf8_enables_everything(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("LANG", "en_US.UTF-8") + monkeypatch.delenv("NO_COLOR", raising=False) + caps = detect(_FakeStream(tty=True, encoding="utf-8")) + assert caps.is_tty + assert caps.can_color + assert caps.can_emoji + + def test_non_tty_disables_everything(self): + caps = detect(_FakeStream(tty=False, encoding="utf-8")) + assert not caps.is_tty + assert not caps.can_color + assert not caps.can_emoji + assert caps.pipe_mode is True + + def test_no_color_env_disables_color_only(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("NO_COLOR", "1") + monkeypatch.setenv("LANG", "en_US.UTF-8") + caps = detect(_FakeStream(tty=True, encoding="utf-8")) + assert caps.is_tty + assert not caps.can_color + assert caps.can_emoji # emoji is still fine without color + + def test_ascii_encoding_disables_emoji(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("LANG", "C") + monkeypatch.delenv("LC_ALL", raising=False) + caps = detect(_FakeStream(tty=True, encoding="ascii")) + assert caps.is_tty + assert not caps.can_emoji + + def test_width_has_sensible_floor(self): + caps = detect(_FakeStream(tty=True, encoding="utf-8")) + assert caps.width >= 40 + + +class TestStyle: + def test_disabled_returns_plain_text(self): + assert style("hello", "bold", "red", enabled=False) == "hello" + + def test_no_codes_returns_plain_text(self): + assert style("hello") == "hello" + + def test_wraps_with_ansi(self): + out = style("hi", "bold", enabled=True) + assert "\x1b[1m" in out + assert "\x1b[0m" in out + assert "hi" in out + + def test_unknown_code_is_ignored(self): + out = style("hi", "nonexistent", enabled=True) + # Nothing to add β†’ raw text with reset, but we skip wrapping when no prefix. + assert out == "hi" From 568867a3c561947e2321ec7b7950ffaf57ddab36 Mon Sep 17 00:00:00 2001 From: Chris Geyer Date: Thu, 16 Apr 2026 20:36:43 +0000 Subject: [PATCH 02/14] feat(run): stronger column definition with vertical rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds thin vertical rules (β”‚, | without UTF-8) between the three columns so the Inputs/Job/Outputs grouping is unmistakable. Previously the columns relied on whitespace alone, which made subsequent rows look disconnected from their headers. Also splits the "next steps" line into two: `roar show --job ` on one line, `roar dag` (or `roar pop` for interrupted runs) on the next. Easier to pick out and copy. The vertical rules are dim, so they anchor visually without competing with the content. The first-row arrow (β†’ or >) sits just past the rule on its way into the destination column. Gutter width grew from 4 to 5 chars to accommodate the rule. Co-Authored-By: Claude Opus 4.6 (1M context) --- roar/presenters/run_report.py | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/roar/presenters/run_report.py b/roar/presenters/run_report.py index b6fb4d64..13594b38 100644 --- a/roar/presenters/run_report.py +++ b/roar/presenters/run_report.py @@ -74,9 +74,11 @@ def for_width(cls, width: int) -> _ColumnPlan: indent = 4 # 2 (margin dot + space) + 2 more for breathing room # Leave a few chars of right-hand slack. available = max(48, width - indent - 2) - gutter = 4 + # Gutter holds " β”‚ β†’ " (or " | > ") = 5 chars; must stay in sync with + # _format_row below. + gutter = 5 col = (available - 2 * gutter) // 3 - col = max(14, min(col, 28)) + col = max(14, min(col, 24)) return cls(col_width=col, gutter=gutter, indent=indent) @@ -397,7 +399,8 @@ def _render_summary(self, result: RunResult, command: list[str]) -> None: next_cmd = style("roar pop", "cyan", enabled=self._caps.can_color) else: next_cmd = style("roar dag", "cyan", enabled=self._caps.can_color) - self._emit_summary(f"{show_cmd} {next_cmd}") + self._emit_summary(show_cmd) + self._emit_summary(next_cmd) self._emit_summary() def _format_row( @@ -410,13 +413,18 @@ def _format_row( arrow: bool, dim_all: bool = False, ) -> str: - """Render a single row with the three columns padded to plan widths.""" + """Render a single row with the three columns padded to plan widths. + + The gutter between columns is 5 chars: ``" β”‚ β†’ "`` on the first data + row (arrow pointing into the next column), ``" β”‚ "`` elsewhere. + The vertical rule ``β”‚`` (or ``|`` without UTF-8) runs the full height + of the block, giving the columns clear visual separation. + """ color = self._caps.can_color and dim_all def styled(s: str) -> str: return style(s, "dim", enabled=color) if color and s else s - # Visible length calculation when the string contains ANSI codes. left_visible = _visible_len(left) mid_visible = _visible_len(mid) right_visible = _visible_len(right) @@ -425,14 +433,16 @@ def styled(s: str) -> str: mid_pad = max(0, plan.col_width - mid_visible) right_pad = max(0, plan.col_width - right_visible) - arrow_str = " β†’ " if self._caps.can_emoji else " > " - gutter_str = " " * (plan.gutter - len(arrow_str)) - if not arrow: - arrow_str = " " * len(arrow_str) + # Gutter: space, rule, space, arrow-or-space, space (5 chars visible). + rule_char = "β”‚" if self._caps.can_emoji else "|" + arrow_char = "β†’" if self._caps.can_emoji else ">" + rule = style(rule_char, "dim", enabled=self._caps.can_color) + arrow_vis = arrow_char if arrow else " " + gutter = f" {rule} {arrow_vis} " return ( - f"{styled(left)}{' ' * left_pad}{gutter_str}{arrow_str}" - f"{styled(mid)}{' ' * mid_pad}{gutter_str}{arrow_str}" + f"{styled(left)}{' ' * left_pad}{gutter}" + f"{styled(mid)}{' ' * mid_pad}{gutter}" f"{styled(right)}{' ' * right_pad}" ) From 0791dc32ef0c9618d4fa6f5fa3a7e1b3e658c4de Mon Sep 17 00:00:00 2001 From: Chris Geyer Date: Fri, 17 Apr 2026 15:17:37 +0000 Subject: [PATCH 03/14] feat(run): iterate on run output with new layout and metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major iteration on the run output design per user feedback: Lifecycle changes: - trace_starting shows tracer/proxy/sync params in compact format - New "hashed N artifacts Β· XX.XMB/s" throughput line after hashing - "lineage captured:" with colon (introduces the block below) - "done" omits total time, shows only "(trace Xs + post Ys)" Summary block redesign: - Removed vertical rule column separators; use color instead (job UID in yellow/bold to distinguish from input/output columns) - Added [Parent] sub-column to inputs showing producing job UID (currently shows "--" placeholder β€” parent lookup not yet plumbed) - Added "git:" line showing branch @ short-commit + clean/dirty - Added "env:" compact summary (N pip β€’ N dpkg β€’ N vars) - Added "Inspect:" section with blue commands + dim "#" comments - roar show and roar dag on separate lines under "Inspect:" label Data plumbing: - RunResult gains: git_branch, git_short_commit, git_clean, total_hash_bytes, hash_duration - Coordinator collects git info via subprocess (best-effort) - Coordinator computes hash throughput from output sizes + timing Stubs/follow-ups: - Parent job UID lookup (needs DB query per input artifact) - DAG summary line (needs session-level aggregate query) - Live m/n hashing counter (needs hook into record_job) - sync: always "off" (future capability) Co-Authored-By: Claude Opus 4.6 (1M context) --- roar/core/models/run.py | 5 + roar/execution/runtime/coordinator.py | 42 ++++ roar/presenters/run_report.py | 279 ++++++++++++-------------- tests/unit/test_run_report.py | 14 +- 4 files changed, 187 insertions(+), 153 deletions(-) diff --git a/roar/core/models/run.py b/roar/core/models/run.py index 58010499..57e9a61b 100644 --- a/roar/core/models/run.py +++ b/roar/core/models/run.py @@ -118,6 +118,11 @@ class RunResult(ImmutableModel): pip_count: int = 0 dpkg_count: int = 0 env_count: int = 0 + git_branch: str | None = None + git_short_commit: str | None = None + git_clean: bool = True + total_hash_bytes: int = 0 + hash_duration: float = 0.0 @computed_field # type: ignore[prop-decorator] @property diff --git a/roar/execution/runtime/coordinator.py b/roar/execution/runtime/coordinator.py index 65c1f256..b4ae4cd2 100644 --- a/roar/execution/runtime/coordinator.py +++ b/roar/execution/runtime/coordinator.py @@ -9,6 +9,7 @@ import os import secrets +import subprocess import sys import time from typing import TYPE_CHECKING, Any @@ -288,6 +289,14 @@ def stop_proxy_if_running() -> list: self.logger.debug("Cleaning up temporary log files") self._cleanup_logs(tracer_result.tracer_log_path, tracer_result.inject_log_path) + # "hashed" throughput line. + total_hash_bytes_early = sum(f.get("size") or 0 for f in written_file_info) + hash_dur = t_record_end - t_record_start + run_presenter.hashed( + n_artifacts=len(read_file_info) + len(written_file_info), + total_bytes=total_hash_bytes_early, + duration=hash_dur, + ) run_presenter.lineage_captured() t_postrun_end = time.perf_counter() @@ -323,6 +332,34 @@ def stop_proxy_if_running() -> list: if not isinstance(backend_name, str): backend_name = None + # Hash throughput: sum of all output sizes + record duration. + total_hash_bytes = sum(f.get("size") or 0 for f in written_file_info) + hash_duration = t_record_end - t_record_start + + # Git info (best-effort, never fail the run for this). + git_branch, git_short_commit, git_clean = None, None, True + try: + git_branch = ( + subprocess.check_output( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + stderr=subprocess.DEVNULL, + text=True, + cwd=ctx.repo_root, + ).strip() + or None + ) + git_short_commit = ( + subprocess.check_output( + ["git", "rev-parse", "--short", "HEAD"], + stderr=subprocess.DEVNULL, + text=True, + cwd=ctx.repo_root, + ).strip() + or None + ) + except Exception: + pass + return RunResult( exit_code=tracer_result.exit_code, job_id=job_id, @@ -340,6 +377,11 @@ def stop_proxy_if_running() -> list: pip_count=pip_count, dpkg_count=dpkg_count, env_count=env_count, + git_branch=git_branch, + git_short_commit=git_short_commit, + git_clean=git_clean, + total_hash_bytes=total_hash_bytes, + hash_duration=hash_duration, ) def _record_job( diff --git a/roar/presenters/run_report.py b/roar/presenters/run_report.py index 13594b38..20757bba 100644 --- a/roar/presenters/run_report.py +++ b/roar/presenters/run_report.py @@ -22,7 +22,6 @@ import re import sys from contextlib import contextmanager -from dataclasses import dataclass from typing import IO from ..core.interfaces.presenter import IPresenter @@ -59,29 +58,6 @@ def _basename(path: str) -> str: # --------------------------------------------------------------------------- -@dataclass(frozen=True) -class _ColumnPlan: - col_width: int - gutter: int - indent: int - - @property - def total(self) -> int: - return self.indent + 3 * self.col_width + 2 * self.gutter - - @classmethod - def for_width(cls, width: int) -> _ColumnPlan: - indent = 4 # 2 (margin dot + space) + 2 more for breathing room - # Leave a few chars of right-hand slack. - available = max(48, width - indent - 2) - # Gutter holds " β”‚ β†’ " (or " | > ") = 5 chars; must stay in sync with - # _format_row below. - gutter = 5 - col = (available - 2 * gutter) // 3 - col = max(14, min(col, 24)) - return cls(col_width=col, gutter=gutter, indent=indent) - - def _truncate(text: str, width: int) -> str: if len(text) <= width: return text @@ -90,13 +66,6 @@ def _truncate(text: str, width: int) -> str: return text[: width - 1] + "…" -def _pad(text: str, width: int) -> str: - visible = len(text) - if visible >= width: - return text - return text + " " * (width - visible) - - # --------------------------------------------------------------------------- # Presenter # --------------------------------------------------------------------------- @@ -134,9 +103,13 @@ def trace_starting(self, backend: str | None, proxy_active: bool) -> None: if self._quiet or self._caps.pipe_mode: return b = backend or "auto" - proxy = "proxy on" if proxy_active else "proxy off" + proxy = "on" if proxy_active else "off" verb = style("tracing", "bold", enabled=self._caps.can_color) - params = style(f"with {b} ({proxy})", "dim", enabled=self._caps.can_color) + params = style( + f"(tracer:{b} proxy:{proxy} sync:off)", + "dim", + enabled=self._caps.can_color, + ) self._emit_lifecycle(f"{verb} {params}") def trace_ended(self, duration: float, exit_code: int) -> None: @@ -173,10 +146,23 @@ def hashing(self, total: int | None = None): with Spinner(label, prefix=prefix, frames=frames, interval=0.1) as sp: yield sp + def hashed(self, n_artifacts: int, total_bytes: int, duration: float) -> None: + """Announce that hashing is complete with throughput.""" + if self._quiet or self._caps.pipe_mode: + return + verb = style("hashed", "bold", enabled=self._caps.can_color) + noun = "artifact" if n_artifacts == 1 else "artifacts" + if duration > 0 and total_bytes > 0: + mbps = (total_bytes / 1024 / 1024) / duration + throughput = style(f" Β· {mbps:.1f}MB/s", "dim", enabled=self._caps.can_color) + else: + throughput = "" + self._emit_lifecycle(f"{verb} {n_artifacts} {noun}{throughput}") + def lineage_captured(self) -> None: if self._quiet or self._caps.pipe_mode: return - verb = style("lineage captured", "bold", enabled=self._caps.can_color) + verb = style("lineage captured:", "bold", enabled=self._caps.can_color) self._emit_lifecycle(verb) def summary(self, result: RunResult, command: list[str]) -> None: @@ -208,14 +194,13 @@ def done( return color = "green" if exit_code == 0 else "red" verb_styled = style(verb, "bold", color, enabled=self._caps.can_color) - total_s = style(self._fmt_duration(total), "bold", enabled=self._caps.can_color) breakdown = style( f"(trace {self._fmt_duration(trace_duration)} + " f"post {self._fmt_duration(post_duration)})", "dim", enabled=self._caps.can_color, ) - self._emit_lifecycle(f"{verb_styled} Β· {total_s} {breakdown}") + self._emit_lifecycle(f"{verb_styled} {breakdown}") # ---- backward-compat one-shot ---------------------------------------- @@ -319,133 +304,133 @@ def _fmt_duration(self, seconds: float) -> str: return f"{seconds:.1f}s" def _render_summary(self, result: RunResult, command: list[str]) -> None: - plan = _ColumnPlan.for_width(self._caps.width) - - inputs = [_basename(f["path"]) for f in result.inputs] - outputs = [_basename(f["path"]) for f in result.outputs] + c = self._caps.can_color + w = self._caps.width + + # Layout widths. + HASH_W = 8 + PARENT_OVERHEAD = 3 # " [" + "]" + ARROW_W = 4 # " -> " or " β†’ " + MARGIN = 4 # "Β· " + padding + fixed = MARGIN + PARENT_OVERHEAD + HASH_W + ARROW_W + HASH_W + ARROW_W + remaining = max(20, w - fixed - 2) + path_w = min(24, remaining // 2) + out_w = min(24, remaining - path_w) + + arrow = ( + style("β†’ ", "dim", enabled=c) if self._caps.can_emoji else style("> ", "dim", enabled=c) + ) + blank_arrow = " " + job_color = "yellow" + inputs = result.inputs + outputs = result.outputs n_in = len(inputs) n_out = len(outputs) - - in_header = f"Inputs ({n_in})" - job_header = f"Job {result.job_uid}" - out_header = f"Outputs ({n_out})" - - # Rows available per column after the header. per_col = 4 - # Left column rows (inputs, with "... and N more" if needed). - in_rows: list[str] = [] - shown_in = min(n_in, per_col - 1) if n_in > per_col else min(n_in, per_col) - for path in inputs[:shown_in]: - in_rows.append(_truncate(path, plan.col_width)) - if n_in > shown_in: - more = style( - f"… and {n_in - shown_in} more", - "dim", - "italic", - enabled=self._caps.can_color, - ) - in_rows.append(more) - - # Middle column rows: package & env counts. - job_rows = [] - if result.pip_count: - job_rows.append(f"{result.pip_count} pip pkgs") - if result.dpkg_count: - job_rows.append(f"{result.dpkg_count} dpkg pkgs") - if result.env_count: - job_rows.append(f"{result.env_count} env vars") - if not job_rows: - job_rows.append(style("β€”", "dim", enabled=self._caps.can_color)) + # -- header -- + self._emit_summary() + in_hdr = style(f"Inputs ({n_in})", "dim", enabled=c) + parent_hdr = style("[Parent ]", "dim", enabled=c) + job_hdr = style("Job", "dim", enabled=c) + out_hdr = style(f"Outputs ({n_out})", "dim", enabled=c) + # Assemble header: Inputs (N) [Parent ] -> Job -> Outputs (M) + in_hdr_text = f"Inputs ({n_in})" + in_pad = max(0, path_w - len(in_hdr_text)) + hdr = ( + f"{in_hdr}{' ' * in_pad} {parent_hdr} {arrow}" + f"{job_hdr}{' ' * max(0, HASH_W - 3)} {arrow}" + f"{out_hdr}" + ) + self._emit_summary(hdr) - # Right column rows (outputs). - out_rows: list[str] = [] + # -- data rows -- + shown_in = min(n_in, per_col - 1) if n_in > per_col else min(n_in, per_col) shown_out = min(n_out, per_col - 1) if n_out > per_col else min(n_out, per_col) - for path in outputs[:shown_out]: - out_rows.append(_truncate(path, plan.col_width)) - if n_out > shown_out: - more = style( - f"… and {n_out - shown_out} more", - "dim", - "italic", - enabled=self._caps.can_color, - ) - out_rows.append(more) - - # -- emit -- - self._emit_summary() - header_line = self._format_row( - in_header, job_header, out_header, plan, arrow=False, dim_all=True + max_rows = max( + shown_in + (1 if n_in > shown_in else 0), shown_out + (1 if n_out > shown_out else 0), 1 ) - self._emit_summary(header_line) - max_rows = max(len(in_rows), len(job_rows), len(out_rows), 1) for i in range(max_rows): - left = in_rows[i] if i < len(in_rows) else "" - mid = job_rows[i] if i < len(job_rows) else "" - right = out_rows[i] if i < len(out_rows) else "" - self._emit_summary(self._format_row(left, mid, right, plan, arrow=(i == 0))) + # Input path + parent. + if i < shown_in: + inp = inputs[i] + ipath = _truncate(_basename(inp["path"]), path_w) + # Parent job UID β€” not yet plumbed; show "--" placeholder. + parent = style("[-- ]", "dim", enabled=c) + elif i == shown_in and n_in > shown_in: + ipath = style(f"… and {n_in - shown_in} more", "dim", "italic", enabled=c) + parent = " " * 10 + else: + ipath = "" + parent = " " * 10 + + # Job column (only on first data row). + if i == 0: + job_val = style(result.job_uid, "bold", job_color, enabled=c) + a = arrow + else: + job_val = " " * HASH_W + a = blank_arrow + + # Output path. + if i < shown_out: + opath = _truncate(_basename(outputs[i]["path"]), out_w) + elif i == shown_out and n_out > shown_out: + opath = style(f"… and {n_out - shown_out} more", "dim", "italic", enabled=c) + else: + opath = "" + + ipath_vis = _visible_len(ipath) + ipad = max(0, path_w - ipath_vis) + job_vis = _visible_len(job_val) + jpad = max(0, HASH_W - job_vis) + + line = f"{ipath}{' ' * ipad} {parent} {a}{job_val}{' ' * jpad} {a}{opath}" + self._emit_summary(line) + + # -- metadata lines -- + self._emit_summary() + # Git info. + if result.git_branch or result.git_short_commit: + branch = result.git_branch or "?" + commit = result.git_short_commit or "?" + clean = "clean" if result.git_clean else "dirty" + git_label = style("git:", "bold", enabled=c) + git_val = style(f" {branch} @ {commit} {clean}", "dim", enabled=c) + self._emit_summary(f"{git_label}{git_val}") + + # Env summary. + parts = [] + if result.pip_count: + parts.append(f"{result.pip_count} pip") + if result.dpkg_count: + parts.append(f"{result.dpkg_count} dpkg") + if result.env_count: + parts.append(f"{result.env_count} vars") + if parts: + env_label = style("env:", "bold", enabled=c) + env_val = style(f" {' β€’ '.join(parts)}", "dim", enabled=c) + self._emit_summary(f"{env_label}{env_val}") + + # Inspect section. self._emit_summary() - show_cmd = style( - f"roar show --job {result.job_uid}", - "cyan", - enabled=self._caps.can_color, - ) - # Offer `roar pop` instead of `roar dag` when the run was interrupted - # and left partial outputs behind (pop removes the partial job). + self._emit_summary(style("Inspect:", "bold", enabled=c)) + show_cmd = style(f"roar show --job {result.job_uid}", "blue", enabled=c) + show_comment = style(" # details", "dim", enabled=c) + self._emit_summary(f" {show_cmd}{show_comment}") if result.interrupted and result.outputs: - next_cmd = style("roar pop", "cyan", enabled=self._caps.can_color) + pop_cmd = style("roar pop", "blue", enabled=c) + pop_comment = style(" # undo interrupted run", "dim", enabled=c) + self._emit_summary(f" {pop_cmd}{pop_comment}") else: - next_cmd = style("roar dag", "cyan", enabled=self._caps.can_color) - self._emit_summary(show_cmd) - self._emit_summary(next_cmd) + dag_cmd = style("roar dag", "blue", enabled=c) + dag_comment = style(" # full lineage", "dim", enabled=c) + self._emit_summary(f" {dag_cmd}{dag_comment}") self._emit_summary() - def _format_row( - self, - left: str, - mid: str, - right: str, - plan: _ColumnPlan, - *, - arrow: bool, - dim_all: bool = False, - ) -> str: - """Render a single row with the three columns padded to plan widths. - - The gutter between columns is 5 chars: ``" β”‚ β†’ "`` on the first data - row (arrow pointing into the next column), ``" β”‚ "`` elsewhere. - The vertical rule ``β”‚`` (or ``|`` without UTF-8) runs the full height - of the block, giving the columns clear visual separation. - """ - color = self._caps.can_color and dim_all - - def styled(s: str) -> str: - return style(s, "dim", enabled=color) if color and s else s - - left_visible = _visible_len(left) - mid_visible = _visible_len(mid) - right_visible = _visible_len(right) - - left_pad = max(0, plan.col_width - left_visible) - mid_pad = max(0, plan.col_width - mid_visible) - right_pad = max(0, plan.col_width - right_visible) - - # Gutter: space, rule, space, arrow-or-space, space (5 chars visible). - rule_char = "β”‚" if self._caps.can_emoji else "|" - arrow_char = "β†’" if self._caps.can_emoji else ">" - rule = style(rule_char, "dim", enabled=self._caps.can_color) - arrow_vis = arrow_char if arrow else " " - gutter = f" {rule} {arrow_vis} " - - return ( - f"{styled(left)}{' ' * left_pad}{gutter}" - f"{styled(mid)}{' ' * mid_pad}{gutter}" - f"{styled(right)}{' ' * right_pad}" - ) - # --------------------------------------------------------------------------- # Helpers diff --git a/tests/unit/test_run_report.py b/tests/unit/test_run_report.py index fd8c5338..7fa76bd4 100644 --- a/tests/unit/test_run_report.py +++ b/tests/unit/test_run_report.py @@ -126,11 +126,12 @@ def test_summary_has_three_column_headers() -> None: ) out = _strip(buf.getvalue()) assert "Inputs (1)" in out - assert "Job abc12345" in out + assert "abc12345" in out # job UID in the data row assert "Outputs (1)" in out - assert "5 pip pkgs" in out - assert "10 dpkg pkgs" in out - assert "3 env vars" in out + assert "5 pip" in out + assert "10 dpkg" in out + assert "3 vars" in out + assert "Inspect:" in out def test_summary_truncates_and_shows_more_indicator() -> None: @@ -217,7 +218,8 @@ def test_trace_starting_uses_plain_prefix_without_emoji() -> None: assert out.startswith("roar:") assert "tracing" in out assert "preload" in out - assert "proxy on" in out + assert "proxy:on" in out + assert "sync:off" in out def test_trace_ended_colors_nonzero_exit() -> None: @@ -237,9 +239,9 @@ def test_done_shows_separate_trace_and_post_durations() -> None: report = RunReportPresenter(stream=buf, caps=_tty_caps()) report.done(exit_code=0, trace_duration=1.3, post_duration=0.3) out = _strip(buf.getvalue()) - assert "1.6s" in out assert "trace 1.3s" in out assert "post 0.3s" in out + assert "done" in out # ---- legacy entry point --------------------------------------------------- From afd78d8ec5a9f4d0209abd7d3400b54631ed3f29 Mon Sep 17 00:00:00 2001 From: Chris Geyer Date: Fri, 17 Apr 2026 15:36:15 +0000 Subject: [PATCH 04/14] feat(run): add artifact hashes and DAG summary to run output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two missing pieces to the run summary block: 1. Short artifact hashes β€” each input and output now shows its primary hash digest (blake3:a1b2c3d4…) on a dim sub-line beneath the path. 2. DAG summary line β€” "DAG: N jobs β€’ M artifacts β€’ depth D" computed from the session's dependency graph via DagDataBuilder. Depth is the longest root-to-leaf chain. Best-effort; never fails the run. Plumbs dag_jobs, dag_artifacts, dag_depth through RunResult. Coordinator computes them after job recording using an additional read-only DB context. Co-Authored-By: Claude Opus 4.6 (1M context) --- roar/core/models/run.py | 3 +++ roar/execution/runtime/coordinator.py | 36 +++++++++++++++++++++++++ roar/presenters/run_report.py | 38 +++++++++++++++++++++++++++ 3 files changed, 77 insertions(+) diff --git a/roar/core/models/run.py b/roar/core/models/run.py index 57e9a61b..e789fdb8 100644 --- a/roar/core/models/run.py +++ b/roar/core/models/run.py @@ -123,6 +123,9 @@ class RunResult(ImmutableModel): git_clean: bool = True total_hash_bytes: int = 0 hash_duration: float = 0.0 + dag_jobs: int = 0 + dag_artifacts: int = 0 + dag_depth: int = 0 @computed_field # type: ignore[prop-decorator] @property diff --git a/roar/execution/runtime/coordinator.py b/roar/execution/runtime/coordinator.py index b4ae4cd2..8e286936 100644 --- a/roar/execution/runtime/coordinator.py +++ b/roar/execution/runtime/coordinator.py @@ -360,6 +360,39 @@ def stop_proxy_if_running() -> list: except Exception: pass + # DAG stats (best-effort, never fail the run for this). + dag_jobs, dag_artifacts, dag_depth = 0, 0, 0 + try: + from ...db.context import create_database_context as _create_db_ctx + from ...presenters.dag_data_builder import DagDataBuilder + + with _create_db_ctx(ctx.roar_dir) as db_ctx: + session = db_ctx.sessions.get_active() + if session: + builder = DagDataBuilder(db_ctx, int(session["id"])) + dag_data = builder.build(expanded=False) + dag_jobs = len(dag_data.get("nodes", [])) + dag_artifacts = len(dag_data.get("artifacts", [])) + # Compute depth: longest dependency chain. + nodes = dag_data.get("nodes", []) + if nodes: + step_deps = {n["step_number"]: n.get("dependencies", []) for n in nodes} + all_steps = set(step_deps) + memo: dict[int, int] = {} + + def _depth(s: int) -> int: + if s in memo: + return memo[s] + children = [x for x in all_steps if s in step_deps.get(x, [])] + d = 1 + max((_depth(ch) for ch in children), default=0) + memo[s] = d + return d + + roots = [s for s in all_steps if not step_deps.get(s)] + dag_depth = max((_depth(r) for r in roots), default=1) if roots else 1 + except Exception: + pass + return RunResult( exit_code=tracer_result.exit_code, job_id=job_id, @@ -382,6 +415,9 @@ def stop_proxy_if_running() -> list: git_clean=git_clean, total_hash_bytes=total_hash_bytes, hash_duration=hash_duration, + dag_jobs=dag_jobs, + dag_artifacts=dag_artifacts, + dag_depth=dag_depth, ) def _record_job( diff --git a/roar/presenters/run_report.py b/roar/presenters/run_report.py index 20757bba..f055f292 100644 --- a/roar/presenters/run_report.py +++ b/roar/presenters/run_report.py @@ -58,6 +58,17 @@ def _basename(path: str) -> str: # --------------------------------------------------------------------------- +def _short_hash(file_info: dict, color: bool) -> str: + """Return a dim short hash string like 'blake3:a1b2c3d4…' for the first hash.""" + hashes = file_info.get("hashes", []) + if not hashes: + return "" + h = hashes[0] + algo = h.get("algorithm", "?") + digest = h.get("digest", "")[:12] + return style(f"{algo}:{digest}…", "dim", enabled=color) + + def _truncate(text: str, width: int) -> str: if len(text) <= width: return text @@ -354,11 +365,13 @@ def _render_summary(self, result: RunResult, command: list[str]) -> None: for i in range(max_rows): # Input path + parent. + in_hash_line = "" if i < shown_in: inp = inputs[i] ipath = _truncate(_basename(inp["path"]), path_w) # Parent job UID β€” not yet plumbed; show "--" placeholder. parent = style("[-- ]", "dim", enabled=c) + in_hash_line = _short_hash(inp, c) elif i == shown_in and n_in > shown_in: ipath = style(f"… and {n_in - shown_in} more", "dim", "italic", enabled=c) parent = " " * 10 @@ -375,8 +388,10 @@ def _render_summary(self, result: RunResult, command: list[str]) -> None: a = blank_arrow # Output path. + out_hash_line = "" if i < shown_out: opath = _truncate(_basename(outputs[i]["path"]), out_w) + out_hash_line = _short_hash(outputs[i], c) elif i == shown_out and n_out > shown_out: opath = style(f"… and {n_out - shown_out} more", "dim", "italic", enabled=c) else: @@ -390,6 +405,16 @@ def _render_summary(self, result: RunResult, command: list[str]) -> None: line = f"{ipath}{' ' * ipad} {parent} {a}{job_val}{' ' * jpad} {a}{opath}" self._emit_summary(line) + # Sub-line with short hashes (dim, indented under path). + if in_hash_line or out_hash_line: + in_sub = in_hash_line if in_hash_line else "" + in_sub_vis = _visible_len(in_sub) + in_sub_pad = max(0, path_w - in_sub_vis) + # Blank parent + blank arrow + blank job + blank arrow = filler for middle cols. + mid_filler = " " * 10 + " " + blank_arrow + " " * HASH_W + " " + blank_arrow + out_sub = out_hash_line if out_hash_line else "" + self._emit_summary(f"{in_sub}{' ' * in_sub_pad} {mid_filler}{out_sub}") + # -- metadata lines -- self._emit_summary() @@ -415,6 +440,19 @@ def _render_summary(self, result: RunResult, command: list[str]) -> None: env_val = style(f" {' β€’ '.join(parts)}", "dim", enabled=c) self._emit_summary(f"{env_label}{env_val}") + # DAG summary. + if result.dag_jobs or result.dag_artifacts: + dag_parts = [] + if result.dag_jobs: + dag_parts.append(f"{result.dag_jobs} jobs") + if result.dag_artifacts: + dag_parts.append(f"{result.dag_artifacts} artifacts") + if result.dag_depth: + dag_parts.append(f"depth {result.dag_depth}") + dag_label = style("DAG:", "bold", enabled=c) + dag_val = style(f" {' β€’ '.join(dag_parts)}", "dim", enabled=c) + self._emit_summary(f"{dag_label}{dag_val}") + # Inspect section. self._emit_summary() self._emit_summary(style("Inspect:", "bold", enabled=c)) From 45f0b079bf9d5b0fc3a9549401f9505e76dd312c Mon Sep 17 00:00:00 2001 From: Chris Geyer Date: Fri, 17 Apr 2026 16:21:37 +0000 Subject: [PATCH 05/14] feat(run): inline hashes, parent job lookup, color-coded columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary block changes per review: - Hashes on same line as filename (8-char digest, no algorithm prefix) - No brackets on Parent column - Color-coded columns: inputs=cyan, job=yellow, outputs=green, parent=dim gray. Headers use same color as their column but dim. Hashes and job UIDs are dim, not bright. - Parent job UID now populated from DB: each input artifact's producing job is looked up via artifacts.get_jobs(). Shows "--" when no producing job is found. - Filenames always shown (truncated with … when needed). Co-Authored-By: Claude Opus 4.6 (1M context) --- roar/execution/runtime/coordinator.py | 11 ++- roar/presenters/run_report.py | 136 ++++++++++++++------------ 2 files changed, 84 insertions(+), 63 deletions(-) diff --git a/roar/execution/runtime/coordinator.py b/roar/execution/runtime/coordinator.py index 8e286936..46ccc454 100644 --- a/roar/execution/runtime/coordinator.py +++ b/roar/execution/runtime/coordinator.py @@ -360,7 +360,7 @@ def stop_proxy_if_running() -> list: except Exception: pass - # DAG stats (best-effort, never fail the run for this). + # DAG stats + parent job lookup (best-effort, never fail the run). dag_jobs, dag_artifacts, dag_depth = 0, 0, 0 try: from ...db.context import create_database_context as _create_db_ctx @@ -390,6 +390,15 @@ def _depth(s: int) -> int: roots = [s for s in all_steps if not step_deps.get(s)] dag_depth = max((_depth(r) for r in roots), default=1) if roots else 1 + + # Parent job UIDs for input artifacts. + for inp in read_file_info: + aid = inp.get("artifact_id") + if aid: + jobs_info = db_ctx.artifacts.get_jobs(aid) + producers = jobs_info.get("produced_by", []) + if producers: + inp["parent_job_uid"] = producers[0].get("job_uid") except Exception: pass diff --git a/roar/presenters/run_report.py b/roar/presenters/run_report.py index f055f292..ad56eccd 100644 --- a/roar/presenters/run_report.py +++ b/roar/presenters/run_report.py @@ -58,15 +58,21 @@ def _basename(path: str) -> str: # --------------------------------------------------------------------------- -def _short_hash(file_info: dict, color: bool) -> str: - """Return a dim short hash string like 'blake3:a1b2c3d4…' for the first hash.""" +def _short_digest(file_info: dict, col_color: str, color_enabled: bool) -> str: + """Return a dim 8-char digest from the first hash, in the column's color.""" hashes = file_info.get("hashes", []) if not hashes: return "" - h = hashes[0] - algo = h.get("algorithm", "?") - digest = h.get("digest", "")[:12] - return style(f"{algo}:{digest}…", "dim", enabled=color) + digest = hashes[0].get("digest", "")[:8] + return style(digest, "dim", col_color, enabled=color_enabled) + + +def _pad_v(text: str, width: int) -> str: + """Pad *text* to *width* visible chars (ignoring ANSI sequences).""" + vis = _visible_len(text) + if vis >= width: + return text + return text + " " * (width - vis) def _truncate(text: str, width: int) -> str: @@ -318,21 +324,28 @@ def _render_summary(self, result: RunResult, command: list[str]) -> None: c = self._caps.can_color w = self._caps.width - # Layout widths. - HASH_W = 8 - PARENT_OVERHEAD = 3 # " [" + "]" - ARROW_W = 4 # " -> " or " β†’ " - MARGIN = 4 # "Β· " + padding - fixed = MARGIN + PARENT_OVERHEAD + HASH_W + ARROW_W + HASH_W + ARROW_W + # Column colors: inputs=cyan, job=yellow, outputs=green, parent=gray. + IN_COLOR = "cyan" + JOB_COLOR = "yellow" + OUT_COLOR = "green" + + # Layout widths β€” each "cell" is , so we split path_w + # and DIGEST_W. Parent and Job are fixed at 8 visible chars. + DIGEST_W = 9 # space + 8-char digest + ID_W = 8 + ARROW_W = 4 # " β†’ " or " > " + MARGIN = 4 + + # path_w is the filename portion; total input cell = path_w + DIGEST_W + 1 + ID_W (parent) + # total output cell = path_w + DIGEST_W + fixed = MARGIN + DIGEST_W + 1 + ID_W + ARROW_W + ID_W + ARROW_W + DIGEST_W remaining = max(20, w - fixed - 2) - path_w = min(24, remaining // 2) - out_w = min(24, remaining - path_w) + path_w = min(22, remaining // 2) + out_path_w = min(22, remaining - path_w) - arrow = ( - style("β†’ ", "dim", enabled=c) if self._caps.can_emoji else style("> ", "dim", enabled=c) - ) - blank_arrow = " " - job_color = "yellow" + arrow_char = "β†’" if self._caps.can_emoji else ">" + arrow = style(f" {arrow_char} ", "dim", enabled=c) + blank_arrow = " " inputs = result.inputs outputs = result.outputs @@ -342,79 +355,78 @@ def _render_summary(self, result: RunResult, command: list[str]) -> None: # -- header -- self._emit_summary() - in_hdr = style(f"Inputs ({n_in})", "dim", enabled=c) - parent_hdr = style("[Parent ]", "dim", enabled=c) - job_hdr = style("Job", "dim", enabled=c) - out_hdr = style(f"Outputs ({n_out})", "dim", enabled=c) - # Assemble header: Inputs (N) [Parent ] -> Job -> Outputs (M) - in_hdr_text = f"Inputs ({n_in})" - in_pad = max(0, path_w - len(in_hdr_text)) - hdr = ( - f"{in_hdr}{' ' * in_pad} {parent_hdr} {arrow}" - f"{job_hdr}{' ' * max(0, HASH_W - 3)} {arrow}" - f"{out_hdr}" - ) + + # Header: same color as column contents but dim. + in_hdr = _pad_v(style(f"Inputs ({n_in})", "dim", IN_COLOR, enabled=c), path_w) + in_hash_hdr = " " * DIGEST_W + parent_hdr = _pad_v(style("Parent", "dim", enabled=c), ID_W) + job_hdr = _pad_v(style("Job", "dim", JOB_COLOR, enabled=c), ID_W) + out_hdr = _pad_v(style(f"Outputs ({n_out})", "dim", OUT_COLOR, enabled=c), out_path_w) + out_hash_hdr = " " * DIGEST_W + + hdr = f"{in_hdr}{in_hash_hdr} {parent_hdr}{arrow}{job_hdr}{arrow}{out_hdr}{out_hash_hdr}" self._emit_summary(hdr) # -- data rows -- shown_in = min(n_in, per_col - 1) if n_in > per_col else min(n_in, per_col) shown_out = min(n_out, per_col - 1) if n_out > per_col else min(n_out, per_col) max_rows = max( - shown_in + (1 if n_in > shown_in else 0), shown_out + (1 if n_out > shown_out else 0), 1 + shown_in + (1 if n_in > shown_in else 0), + shown_out + (1 if n_out > shown_out else 0), + 1, ) for i in range(max_rows): - # Input path + parent. - in_hash_line = "" + # --- left: input path + hash + parent --- if i < shown_in: inp = inputs[i] - ipath = _truncate(_basename(inp["path"]), path_w) - # Parent job UID β€” not yet plumbed; show "--" placeholder. - parent = style("[-- ]", "dim", enabled=c) - in_hash_line = _short_hash(inp, c) + ipath = style(_truncate(_basename(inp["path"]), path_w), IN_COLOR, enabled=c) + ihash = _short_digest(inp, IN_COLOR, c) + parent_uid = inp.get("parent_job_uid") + if parent_uid: + parent_val = style(str(parent_uid)[:ID_W], "dim", enabled=c) + else: + parent_val = style("--", "dim", enabled=c) elif i == shown_in and n_in > shown_in: ipath = style(f"… and {n_in - shown_in} more", "dim", "italic", enabled=c) - parent = " " * 10 + ihash = "" + parent_val = "" else: ipath = "" - parent = " " * 10 + ihash = "" + parent_val = "" - # Job column (only on first data row). + # --- center: job --- if i == 0: - job_val = style(result.job_uid, "bold", job_color, enabled=c) + job_val = style(result.job_uid, "dim", JOB_COLOR, enabled=c) a = arrow else: - job_val = " " * HASH_W + job_val = "" a = blank_arrow - # Output path. - out_hash_line = "" + # --- right: output path + hash --- if i < shown_out: - opath = _truncate(_basename(outputs[i]["path"]), out_w) - out_hash_line = _short_hash(outputs[i], c) + out = outputs[i] + opath = style(_truncate(_basename(out["path"]), out_path_w), OUT_COLOR, enabled=c) + ohash = _short_digest(out, OUT_COLOR, c) elif i == shown_out and n_out > shown_out: opath = style(f"… and {n_out - shown_out} more", "dim", "italic", enabled=c) + ohash = "" else: opath = "" + ohash = "" - ipath_vis = _visible_len(ipath) - ipad = max(0, path_w - ipath_vis) - job_vis = _visible_len(job_val) - jpad = max(0, HASH_W - job_vis) + # Pad each cell to its width using visible length. + ipath_s = _pad_v(ipath, path_w) + ihash_s = _pad_v(ihash, DIGEST_W) if ihash else " " * DIGEST_W + parent_s = _pad_v(parent_val, ID_W) if parent_val else " " * ID_W + job_s = _pad_v(job_val, ID_W) if job_val else " " * ID_W + opath_s = _pad_v(opath, out_path_w) + ohash_s = _pad_v(ohash, DIGEST_W) if ohash else " " * DIGEST_W - line = f"{ipath}{' ' * ipad} {parent} {a}{job_val}{' ' * jpad} {a}{opath}" + line = f"{ipath_s}{ihash_s} {parent_s}{a}{job_s}{a}{opath_s}{ohash_s}" self._emit_summary(line) - # Sub-line with short hashes (dim, indented under path). - if in_hash_line or out_hash_line: - in_sub = in_hash_line if in_hash_line else "" - in_sub_vis = _visible_len(in_sub) - in_sub_pad = max(0, path_w - in_sub_vis) - # Blank parent + blank arrow + blank job + blank arrow = filler for middle cols. - mid_filler = " " * 10 + " " + blank_arrow + " " * HASH_W + " " + blank_arrow - out_sub = out_hash_line if out_hash_line else "" - self._emit_summary(f"{in_sub}{' ' * in_sub_pad} {mid_filler}{out_sub}") - # -- metadata lines -- self._emit_summary() From f7d03eb78c201269347b18b389e5d8c90a1cab86 Mon Sep 17 00:00:00 2001 From: Chris Geyer Date: Fri, 17 Apr 2026 17:23:10 +0000 Subject: [PATCH 06/14] feat(run): v7 section-based layout per redesign spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the layout from roar_run_output_redesign.md: - Five distinct sections (Inputs, Job, Outputs, DAG, Inspect) with bold green headers and indented data rows, replacing the three-column table. - Emoji progression: πŸ¦– β†’ πŸ¦– β†’ πŸ«† β†’ 🧬 for the lifecycle phases. - exit code appears before duration in the trace-done line. - Color tokens: status_green (256-color #35), command_blue (#74), dim, bold. No raw ANSI codes outside terminal.py. - Hashes carry no hue β€” weight only: bold (current job), regular (artifact digests), dim (source-job hashes). - "Source Job" replaces "Parent" in Inputs section. - Job section is key-value: id (bold), git, env. - Singular/plural: "1 artifact", "1 job", "1 var", etc. - 2-space section indent, 4-space row indent. - Column headers on section-header line, dim, aligned with data. Co-Authored-By: Claude Opus 4.6 (1M context) --- roar/presenters/run_report.py | 539 ++++++++++++++-------------------- roar/presenters/terminal.py | 4 + tests/unit/test_run_report.py | 248 ++++++++++------ 3 files changed, 383 insertions(+), 408 deletions(-) diff --git a/roar/presenters/run_report.py b/roar/presenters/run_report.py index ad56eccd..98b391c8 100644 --- a/roar/presenters/run_report.py +++ b/roar/presenters/run_report.py @@ -1,19 +1,20 @@ -"""Run report presenter. - -Renders the lifecycle of a `roar run` as a series of brief, prefixed lines -followed by a three-column summary block. All output goes to stderr so the -user command's stdout remains clean for piping. - -Design points: -* πŸ¦– prefix on lifecycle lines when emoji is supported, `roar:` otherwise. -* Middle-dot `Β·` prefix on the summary block lines (same idea as πŸ¦– but - quieter β€” keeps the block visually tied to roar without repeating branding). -* Arrow between columns on the first data row only β€” direction of flow shown - once, not on every row. -* Trace duration and post-processing duration reported separately so the - overhead of roar is visible. -* When stderr is not a TTY (piped / redirected / captured), drop to a single - one-line "done" summary. +"""Run report presenter β€” v7 section-based layout. + +Renders the lifecycle of ``roar run`` as status lines followed by five +distinct sections (Inputs, Job, Outputs, DAG, Inspect). All output +goes to stderr so the user command's stdout remains clean for piping. + +Color tokens (via ``terminal.style``): + status_green -status lines, section headers (bold) + command_blue -actionable commands in Inspect block + dim -column headers, metadata labels, source-job hashes, + counts in parentheses, comments, timing breakdown + bold -current job hash (emphasis, no hue) + +Hashes carry NO hue. Weight alone distinguishes them: + bold -the current job hash (once, in the Job block) + regular -artifact hashes in Inputs / Outputs + dim -source-job hashes (context) """ from __future__ import annotations @@ -29,21 +30,45 @@ from .spinner import BRAILLE_FRAMES, CLOCK_FRAMES, Spinner from .terminal import TerminalCaps, detect, style +# --------------------------------------------------------------------------- +# Layout constants +# --------------------------------------------------------------------------- -def format_size(size_bytes: int | None) -> str: - """Format file size in human-readable format.""" - if size_bytes is None: - return "?" - size: float = float(size_bytes) - for unit in ["B", "KB", "MB", "GB"]: - if abs(size) < 1024: - return f"{size:.1f}{unit}" if unit != "B" else f"{int(size)}{unit}" - size /= 1024 - return f"{size:.1f}TB" +_PATH_W = 20 # artifact-name column width +_HASH_W = 8 # fixed 8-char digest +_COL_GAP = 4 # spaces between hash columns +_INDENT = " " # section-header indent (2 spaces) +_ROW_INDENT = " " # data-row indent (4 spaces) +_MAX_ROWS = 5 # max visible rows per section before "… and N more" + + +def _plural(n: int, singular: str, plural: str | None = None) -> str: + return f"{n} {singular}" if n == 1 else f"{n} {plural or singular + 's'}" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") + + +def _visible_len(s: str) -> int: + return len(_ANSI_RE.sub("", s)) + + +def _pad(text: str, width: int) -> str: + vis = _visible_len(text) + return text + " " * max(0, width - vis) + + +def _truncate(text: str, width: int) -> str: + if len(text) <= width: + return text + return text[: max(1, width - 1)] + "…" def _basename(path: str) -> str: - """Best-effort relative path for display; falls back to basename.""" try: rel = os.path.relpath(path) if not rel.startswith(".."): @@ -53,34 +78,36 @@ def _basename(path: str) -> str: return os.path.basename(path) or path -# --------------------------------------------------------------------------- -# Column layout -# --------------------------------------------------------------------------- - - -def _short_digest(file_info: dict, col_color: str, color_enabled: bool) -> str: - """Return a dim 8-char digest from the first hash, in the column's color.""" +def _digest8(file_info: dict) -> str: hashes = file_info.get("hashes", []) if not hashes: return "" - digest = hashes[0].get("digest", "")[:8] - return style(digest, "dim", col_color, enabled=color_enabled) + return hashes[0].get("digest", "")[:_HASH_W] -def _pad_v(text: str, width: int) -> str: - """Pad *text* to *width* visible chars (ignoring ANSI sequences).""" - vis = _visible_len(text) - if vis >= width: - return text - return text + " " * (width - vis) +def format_size(size_bytes: int | None) -> str: + """Format file size in human-readable format.""" + if size_bytes is None: + return "?" + size: float = float(size_bytes) + for unit in ["B", "KB", "MB", "GB"]: + if abs(size) < 1024: + return f"{size:.1f}{unit}" if unit != "B" else f"{int(size)}{unit}" + size /= 1024 + return f"{size:.1f}TB" -def _truncate(text: str, width: int) -> str: - if len(text) <= width: - return text - if width <= 1: - return text[:width] - return text[: width - 1] + "…" +class _NullProgress: + """No-op stand-in returned by ``hashing()`` when in pipe/quiet mode.""" + + def advance(self, delta: int = 1) -> None: + pass + + def set_count(self, count: int) -> None: + pass + + def update(self, message: str) -> None: + pass # --------------------------------------------------------------------------- @@ -89,14 +116,7 @@ def _truncate(text: str, width: int) -> str: class RunReportPresenter: - """Formats and displays run lifecycle events and the final summary. - - The legacy entry point ``show_report(result, command, quiet)`` is preserved - for backward compatibility; it renders the new one-shot summary. Callers - that want lifecycle output (``trace_starting`` β†’ ``trace_ended`` β†’ hashing - spinner β†’ ``lineage_captured`` β†’ ``summary`` β†’ ``done``) can invoke those - methods individually. - """ + """Formats and displays the ``roar run`` lifecycle and summary.""" def __init__( self, @@ -106,8 +126,6 @@ def __init__( caps: TerminalCaps | None = None, quiet: bool = False, ) -> None: - # `presenter` is retained only so that ``show_stale_warnings`` can - # continue to use it; new output always goes to stderr. self._out = presenter self._stream = stream if stream is not None else sys.stderr self._caps = caps if caps is not None else detect(self._stream) @@ -116,77 +134,72 @@ def __init__( # ---- lifecycle events ------------------------------------------------- def trace_starting(self, backend: str | None, proxy_active: bool) -> None: - """Announce that tracing is about to start.""" if self._quiet or self._caps.pipe_mode: return - b = backend or "auto" - proxy = "on" if proxy_active else "off" - verb = style("tracing", "bold", enabled=self._caps.can_color) + c = self._caps.can_color + label = style("tracing", "bold", "status_green", enabled=c) params = style( - f"(tracer:{b} proxy:{proxy} sync:off)", + f"tracer:{backend or 'auto'} proxy:{'on' if proxy_active else 'off'} sync:off", "dim", - enabled=self._caps.can_color, + enabled=c, ) - self._emit_lifecycle(f"{verb} {params}") + self._emit_status(label, params) def trace_ended(self, duration: float, exit_code: int) -> None: - """Announce that the traced command has exited.""" if self._quiet or self._caps.pipe_mode: return - verb = style("trace done", "bold", enabled=self._caps.can_color) - dur = self._fmt_duration(duration) - exit_text = f"exit {exit_code}" - if exit_code != 0 and self._caps.can_color: - exit_text = style(exit_text, "red", "bold", enabled=True) - elif exit_code == 0 and self._caps.can_color: - exit_text = style(exit_text, "dim", enabled=True) - self._emit_lifecycle(f"{verb} Β· {dur} Β· {exit_text}") + c = self._caps.can_color + label = style("trace done", "bold", "status_green", enabled=c) + exit_s = f"exit {exit_code}" + if exit_code != 0: + exit_s = style(exit_s, "red", "bold", enabled=c) + else: + exit_s = style(exit_s, "status_green", enabled=c) + dur_s = style(f"Β· {self._fmt_dur(duration)}", "dim", enabled=c) + self._emit_status(label, f"{exit_s} {dur_s}") @contextmanager def hashing(self, total: int | None = None): - """Context manager: render a spinner for the hashing/recording phase. - - *total*, if given, is displayed as "(N artifacts)" after the label β€” - the counter does not currently update live during hashing, so we show - the static total rather than a misleading "0/N" that never ticks. - """ if self._quiet or self._caps.pipe_mode: yield _NullProgress() return - prefix = self._lifecycle_prefix() - label = style("hashing", "bold", enabled=self._caps.can_color) + c = self._caps.can_color + prefix = self._emoji("πŸ¦–") + " " + label = style("hashing", "bold", "status_green", enabled=c) if total: - noun = "artifact" if total == 1 else "artifacts" - count_str = style(f" ({total} {noun})", "dim", enabled=self._caps.can_color) - label = f"{label}{count_str}" + label += style(f" ({_plural(total, 'artifact')})", "dim", enabled=c) frames = CLOCK_FRAMES if self._caps.can_emoji else BRAILLE_FRAMES with Spinner(label, prefix=prefix, frames=frames, interval=0.1) as sp: yield sp def hashed(self, n_artifacts: int, total_bytes: int, duration: float) -> None: - """Announce that hashing is complete with throughput.""" if self._quiet or self._caps.pipe_mode: return - verb = style("hashed", "bold", enabled=self._caps.can_color) - noun = "artifact" if n_artifacts == 1 else "artifacts" + c = self._caps.can_color + label = style( + f"hashed {_plural(n_artifacts, 'artifact')}", + "bold", + "status_green", + enabled=c, + ) if duration > 0 and total_bytes > 0: mbps = (total_bytes / 1024 / 1024) / duration - throughput = style(f" Β· {mbps:.1f}MB/s", "dim", enabled=self._caps.can_color) + tp = style(f"{mbps:.1f} MB/s", "dim", enabled=c) else: - throughput = "" - self._emit_lifecycle(f"{verb} {n_artifacts} {noun}{throughput}") + tp = "" + self._emit_status(label, tp, emoji="πŸ«†") def lineage_captured(self) -> None: if self._quiet or self._caps.pipe_mode: return - verb = style("lineage captured:", "bold", enabled=self._caps.can_color) - self._emit_lifecycle(verb) + c = self._caps.can_color + label = style("lineage captured", "bold", "status_green", enabled=c) + self._emit_status(label, emoji="🧬") def summary(self, result: RunResult, command: list[str]) -> None: - """Render the three-column inputs/job/outputs block.""" if self._quiet or self._caps.pipe_mode: return - self._render_summary(result, command) + self._render_summary(result) def done( self, @@ -195,43 +208,30 @@ def done( trace_duration: float, post_duration: float, ) -> None: - """Emit the final line. Falls back to a one-liner in pipe mode.""" if self._quiet: return - total = trace_duration + post_duration verb = "done" if exit_code == 0 else "failed" if self._caps.pipe_mode: - # Minimal one-liner when piping. + total = trace_duration + post_duration line = ( - f"roar: {verb} Β· {self._fmt_duration(total)} " - f"(trace {self._fmt_duration(trace_duration)} + " - f"post {self._fmt_duration(post_duration)}, exit {exit_code})" + f"roar: {verb} Β· {self._fmt_dur(total)} " + f"(trace {self._fmt_dur(trace_duration)} + " + f"post {self._fmt_dur(post_duration)}, exit {exit_code})" ) - print(line, file=self._stream, flush=True) + self._print(line) return - color = "green" if exit_code == 0 else "red" - verb_styled = style(verb, "bold", color, enabled=self._caps.can_color) + c = self._caps.can_color + label = style(verb, "bold", "status_green" if exit_code == 0 else "red", enabled=c) breakdown = style( - f"(trace {self._fmt_duration(trace_duration)} + " - f"post {self._fmt_duration(post_duration)})", + f"(trace {self._fmt_dur(trace_duration)} + post {self._fmt_dur(post_duration)})", "dim", - enabled=self._caps.can_color, + enabled=c, ) - self._emit_lifecycle(f"{verb_styled} {breakdown}") + self._emit_status(label, breakdown) # ---- backward-compat one-shot ---------------------------------------- - def show_report( - self, - result: RunResult, - command: list[str], - quiet: bool = False, - ) -> None: - """Render the full lifecycle in one call using data in *result*. - - Used by application/run/execution.py when the run has already - finished and we only have the RunResult to work with. - """ + def show_report(self, result: RunResult, command: list[str], quiet: bool = False) -> None: if quiet or self._quiet: return if self._caps.pipe_mode: @@ -241,19 +241,16 @@ def show_report( post_duration=result.post_duration, ) return - # The trace_starting / trace_ended / hashing / lineage_captured lines - # are ideally emitted during the run itself. When this method is the - # only entry point we skip those and render the meaningful tail. self.trace_ended(result.duration, result.exit_code) self.lineage_captured() - self._render_summary(result, command) + self._render_summary(result) self.done( exit_code=result.exit_code, trace_duration=result.duration, post_duration=result.post_duration, ) - # ---- stale warnings (unchanged semantics) ---------------------------- + # ---- stale warnings (unchanged) -------------------------------------- def show_stale_warnings( self, @@ -276,11 +273,7 @@ def show_stale_warnings( self._out.print(f"Warning: Downstream steps are stale: {step_refs}") self._out.print("Run these steps to update them, or use 'roar dag' to see full status.") - def show_upstream_stale_warning( - self, - step_num: int, - upstream_stale: list[int], - ) -> bool: + def show_upstream_stale_warning(self, step_num: int, upstream_stale: list[int]) -> bool: if self._out is None: return True step_refs = ", ".join(f"@{s}" for s in upstream_stale) @@ -291,218 +284,140 @@ def show_upstream_stale_warning( self._out.print("") return self._out.confirm("Run anyway?", default=False) - # ---- internals ------------------------------------------------------- + # ---- internal: output primitives ------------------------------------- - def _lifecycle_prefix(self) -> str: - return "πŸ¦– " if self._caps.can_emoji else "roar: " + def _emoji(self, char: str) -> str: + return char if self._caps.can_emoji else "roar:" - def _summary_prefix(self) -> str: - """Subtle line prefix for the summary block β€” dim middle-dot.""" - dot = "Β·" if self._caps.can_emoji else "." - return style(f"{dot} ", "dim", enabled=self._caps.can_color) + def _print(self, line: str = "") -> None: + print(line, file=self._stream, flush=True) - def _emit_lifecycle(self, message: str) -> None: - prefix = self._lifecycle_prefix() - color = self._caps.can_color - brand = style(prefix.rstrip(), "magenta", enabled=color) - print(f"{brand} {message}", file=self._stream, flush=True) + def _emit_status(self, label: str, value: str = "", *, emoji: str = "πŸ¦–") -> None: + """Emit a two-column status line: ``πŸ¦– label value``.""" + prefix = self._emoji(emoji) + # Pad label to a fixed width so values align in a column. + padded_label = _pad(f"{prefix} {label}", 28 + _visible_len(prefix)) + self._print(f"{padded_label}{value}") - def _emit_summary(self, line: str = "") -> None: - if line: - print(f"{self._summary_prefix()}{line}", file=self._stream, flush=True) - else: - print(self._summary_prefix().rstrip(), file=self._stream, flush=True) + def _section_header(self, title: str, col_headers: str = "") -> None: + c = self._caps.can_color + styled = style(title, "bold", "status_green", enabled=c) + self._print(f"{_INDENT}{styled}{col_headers}") - def _fmt_duration(self, seconds: float) -> str: - # Keep it tight: sub-second in ms, else one decimal. + def _section_row(self, text: str) -> None: + self._print(f"{_ROW_INDENT}{text}") + + def _fmt_dur(self, seconds: float) -> str: if seconds < 0.1: - ms = max(1, round(seconds * 1000)) - return f"{ms}ms" + return f"{max(1, round(seconds * 1000))}ms" return f"{seconds:.1f}s" - def _render_summary(self, result: RunResult, command: list[str]) -> None: + # ---- internal: summary block ----------------------------------------- + + def _render_summary(self, result: RunResult) -> None: c = self._caps.can_color - w = self._caps.width - - # Column colors: inputs=cyan, job=yellow, outputs=green, parent=gray. - IN_COLOR = "cyan" - JOB_COLOR = "yellow" - OUT_COLOR = "green" - - # Layout widths β€” each "cell" is , so we split path_w - # and DIGEST_W. Parent and Job are fixed at 8 visible chars. - DIGEST_W = 9 # space + 8-char digest - ID_W = 8 - ARROW_W = 4 # " β†’ " or " > " - MARGIN = 4 - - # path_w is the filename portion; total input cell = path_w + DIGEST_W + 1 + ID_W (parent) - # total output cell = path_w + DIGEST_W - fixed = MARGIN + DIGEST_W + 1 + ID_W + ARROW_W + ID_W + ARROW_W + DIGEST_W - remaining = max(20, w - fixed - 2) - path_w = min(22, remaining // 2) - out_path_w = min(22, remaining - path_w) - - arrow_char = "β†’" if self._caps.can_emoji else ">" - arrow = style(f" {arrow_char} ", "dim", enabled=c) - blank_arrow = " " inputs = result.inputs outputs = result.outputs n_in = len(inputs) n_out = len(outputs) - per_col = 4 - - # -- header -- - self._emit_summary() - - # Header: same color as column contents but dim. - in_hdr = _pad_v(style(f"Inputs ({n_in})", "dim", IN_COLOR, enabled=c), path_w) - in_hash_hdr = " " * DIGEST_W - parent_hdr = _pad_v(style("Parent", "dim", enabled=c), ID_W) - job_hdr = _pad_v(style("Job", "dim", JOB_COLOR, enabled=c), ID_W) - out_hdr = _pad_v(style(f"Outputs ({n_out})", "dim", OUT_COLOR, enabled=c), out_path_w) - out_hash_hdr = " " * DIGEST_W - - hdr = f"{in_hdr}{in_hash_hdr} {parent_hdr}{arrow}{job_hdr}{arrow}{out_hdr}{out_hash_hdr}" - self._emit_summary(hdr) - - # -- data rows -- - shown_in = min(n_in, per_col - 1) if n_in > per_col else min(n_in, per_col) - shown_out = min(n_out, per_col - 1) if n_out > per_col else min(n_out, per_col) - max_rows = max( - shown_in + (1 if n_in > shown_in else 0), - shown_out + (1 if n_out > shown_out else 0), - 1, - ) - - for i in range(max_rows): - # --- left: input path + hash + parent --- - if i < shown_in: - inp = inputs[i] - ipath = style(_truncate(_basename(inp["path"]), path_w), IN_COLOR, enabled=c) - ihash = _short_digest(inp, IN_COLOR, c) - parent_uid = inp.get("parent_job_uid") - if parent_uid: - parent_val = style(str(parent_uid)[:ID_W], "dim", enabled=c) - else: - parent_val = style("--", "dim", enabled=c) - elif i == shown_in and n_in > shown_in: - ipath = style(f"… and {n_in - shown_in} more", "dim", "italic", enabled=c) - ihash = "" - parent_val = "" - else: - ipath = "" - ihash = "" - parent_val = "" - - # --- center: job --- - if i == 0: - job_val = style(result.job_uid, "dim", JOB_COLOR, enabled=c) - a = arrow - else: - job_val = "" - a = blank_arrow - - # --- right: output path + hash --- - if i < shown_out: - out = outputs[i] - opath = style(_truncate(_basename(out["path"]), out_path_w), OUT_COLOR, enabled=c) - ohash = _short_digest(out, OUT_COLOR, c) - elif i == shown_out and n_out > shown_out: - opath = style(f"… and {n_out - shown_out} more", "dim", "italic", enabled=c) - ohash = "" - else: - opath = "" - ohash = "" - # Pad each cell to its width using visible length. - ipath_s = _pad_v(ipath, path_w) - ihash_s = _pad_v(ihash, DIGEST_W) if ihash else " " * DIGEST_W - parent_s = _pad_v(parent_val, ID_W) if parent_val else " " * ID_W - job_s = _pad_v(job_val, ID_W) if job_val else " " * ID_W - opath_s = _pad_v(opath, out_path_w) - ohash_s = _pad_v(ohash, DIGEST_W) if ohash else " " * DIGEST_W - - line = f"{ipath_s}{ihash_s} {parent_s}{a}{job_s}{a}{opath_s}{ohash_s}" - self._emit_summary(line) + self._print() + + # -- Inputs section -- + hash_hdr = _pad(style("Hash", "dim", enabled=c), _HASH_W + _COL_GAP) + src_hdr = style("Source Job", "dim", enabled=c) + count_dim = style(f" ({n_in})", "dim", enabled=c) + self._section_header(f"Inputs{count_dim}", f"{'':>{_PATH_W - 6}}{hash_hdr}{src_hdr}") + + shown_in = min(n_in, _MAX_ROWS - 1) if n_in > _MAX_ROWS else n_in + for i in range(shown_in): + inp = inputs[i] + name = _pad(_truncate(_basename(inp["path"]), _PATH_W), _PATH_W) + digest = _pad(_digest8(inp), _HASH_W + _COL_GAP) + parent_uid = inp.get("parent_job_uid") + src = ( + style(str(parent_uid)[:_HASH_W], "dim", enabled=c) + if parent_uid + else style("--", "dim", enabled=c) + ) + self._section_row(f"{name}{digest}{src}") + if n_in > shown_in: + self._section_row(style(f"… and {n_in - shown_in} more", "dim", "italic", enabled=c)) - # -- metadata lines -- - self._emit_summary() + self._print() - # Git info. + # -- Job section -- + self._section_header("Job") + job_id = style(result.job_uid, "bold", enabled=c) + id_label = style("id ", "dim", enabled=c) + self._section_row(f"{id_label} {job_id}") if result.git_branch or result.git_short_commit: branch = result.git_branch or "?" commit = result.git_short_commit or "?" - clean = "clean" if result.git_clean else "dirty" - git_label = style("git:", "bold", enabled=c) - git_val = style(f" {branch} @ {commit} {clean}", "dim", enabled=c) - self._emit_summary(f"{git_label}{git_val}") - - # Env summary. - parts = [] + clean_s = "clean" if result.git_clean else "dirty" + if result.git_clean: + clean_s = style(clean_s, "status_green", enabled=c) + else: + clean_s = style(clean_s, "red", enabled=c) + git_label = style("git", "dim", enabled=c) + self._section_row(f"{git_label} {branch} @ {commit} {clean_s}") + env_parts = [] if result.pip_count: - parts.append(f"{result.pip_count} pip") + env_parts.append(_plural(result.pip_count, "pip")) if result.dpkg_count: - parts.append(f"{result.dpkg_count} dpkg") + env_parts.append(_plural(result.dpkg_count, "dpkg")) if result.env_count: - parts.append(f"{result.env_count} vars") - if parts: - env_label = style("env:", "bold", enabled=c) - env_val = style(f" {' β€’ '.join(parts)}", "dim", enabled=c) - self._emit_summary(f"{env_label}{env_val}") - - # DAG summary. + env_parts.append(_plural(result.env_count, "var")) + if env_parts: + env_label = style("env", "dim", enabled=c) + env_val = style(" Β· ".join(env_parts), "dim", enabled=c) + self._section_row(f"{env_label} {env_val}") + + self._print() + + # -- Outputs section -- + out_hash_hdr = style("Hash", "dim", enabled=c) + count_dim = style(f" ({n_out})", "dim", enabled=c) + self._section_header(f"Outputs{count_dim}", f"{'':>{_PATH_W - 7}}{out_hash_hdr}") + + shown_out = min(n_out, _MAX_ROWS - 1) if n_out > _MAX_ROWS else n_out + for i in range(shown_out): + out = outputs[i] + name = _pad(_truncate(_basename(out["path"]), _PATH_W), _PATH_W) + digest = _digest8(out) + self._section_row(f"{name}{digest}") + if n_out > shown_out: + self._section_row(style(f"… and {n_out - shown_out} more", "dim", "italic", enabled=c)) + + self._print() + + # -- DAG section -- if result.dag_jobs or result.dag_artifacts: + self._section_header("DAG") dag_parts = [] if result.dag_jobs: - dag_parts.append(f"{result.dag_jobs} jobs") + dag_parts.append(_plural(result.dag_jobs, "job")) if result.dag_artifacts: - dag_parts.append(f"{result.dag_artifacts} artifacts") + dag_parts.append(_plural(result.dag_artifacts, "artifact")) if result.dag_depth: dag_parts.append(f"depth {result.dag_depth}") - dag_label = style("DAG:", "bold", enabled=c) - dag_val = style(f" {' β€’ '.join(dag_parts)}", "dim", enabled=c) - self._emit_summary(f"{dag_label}{dag_val}") - - # Inspect section. - self._emit_summary() - self._emit_summary(style("Inspect:", "bold", enabled=c)) - show_cmd = style(f"roar show --job {result.job_uid}", "blue", enabled=c) - show_comment = style(" # details", "dim", enabled=c) - self._emit_summary(f" {show_cmd}{show_comment}") + dag_val = style(" Β· ".join(dag_parts), "dim", enabled=c) + self._section_row(dag_val) + self._print() + + # -- Inspect section -- + self._section_header("Inspect") + show_cmd = style(f"roar show --job {result.job_uid}", "command_blue", enabled=c) + show_comment = style(" # details", "dim", enabled=c) + self._section_row(f"{show_cmd}{show_comment}") if result.interrupted and result.outputs: - pop_cmd = style("roar pop", "blue", enabled=c) - pop_comment = style(" # undo interrupted run", "dim", enabled=c) - self._emit_summary(f" {pop_cmd}{pop_comment}") + pop_cmd = style("roar pop", "command_blue", enabled=c) + pop_comment = style(" # undo interrupted run", "dim", enabled=c) + self._section_row(f"{pop_cmd}{pop_comment}") else: - dag_cmd = style("roar dag", "blue", enabled=c) - dag_comment = style(" # full lineage", "dim", enabled=c) - self._emit_summary(f" {dag_cmd}{dag_comment}") - self._emit_summary() - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") - + dag_cmd = style("roar dag", "command_blue", enabled=c) + dag_comment = style(" # full lineage", "dim", enabled=c) + self._section_row(f"{dag_cmd}{dag_comment}") -def _visible_len(s: str) -> int: - """Length of a string ignoring ANSI escape sequences.""" - return len(_ANSI_RE.sub("", s)) - - -class _NullProgress: - """No-op stand-in returned by Presenter.hashing() when in pipe mode.""" - - def advance(self, delta: int = 1) -> None: - pass - - def set_count(self, count: int) -> None: - pass - - def update(self, message: str) -> None: - pass + self._print() diff --git a/roar/presenters/terminal.py b/roar/presenters/terminal.py index 406658ff..8740e5b8 100644 --- a/roar/presenters/terminal.py +++ b/roar/presenters/terminal.py @@ -65,6 +65,10 @@ def detect(stream: IO | None = None) -> TerminalCaps: "magenta": "\033[35m", "cyan": "\033[36m", "gray": "\033[90m", + # Named semantic tokens β€” 256-color palette entries that read well on + # both dark and light terminal backgrounds. + "status_green": "\033[38;5;35m", # ANSI-256 #35 β€” muted green + "command_blue": "\033[38;5;74m", # ANSI-256 #74 β€” steel blue } diff --git a/tests/unit/test_run_report.py b/tests/unit/test_run_report.py index 7fa76bd4..3e1a2dcc 100644 --- a/tests/unit/test_run_report.py +++ b/tests/unit/test_run_report.py @@ -1,4 +1,4 @@ -"""Tests for RunReportPresenter β€” the new lifecycle-style run output.""" +"""Tests for RunReportPresenter β€” v7 section-based layout.""" from __future__ import annotations @@ -18,7 +18,6 @@ def _strip(s: str) -> str: def _tty_caps(width: int = 120) -> TerminalCaps: - """Force TTY-like caps so presenter renders the full summary.""" return TerminalCaps(is_tty=True, can_color=False, can_emoji=False, width=width) @@ -45,114 +44,176 @@ def print_job(self, job: dict[str, Any], verbose: bool = False) -> None: def print_artifact(self, artifact: dict[str, Any]) -> None: return None - def print_dag( - self, - summary: dict[str, Any], - stale_steps: set[int] | None = None, - ) -> None: + def print_dag(self, summary: dict[str, Any], stale_steps: set[int] | None = None) -> None: return None def confirm(self, message: str, default: bool = False) -> bool: return default -# ---- summary content ------------------------------------------------------ +# ---- section layout ------------------------------------------------------- -def test_interrupted_run_with_outputs_suggests_pop() -> None: +def test_inputs_section_with_source_job() -> None: buf = io.StringIO() report = RunReportPresenter(stream=buf, caps=_tty_caps()) - report.summary( RunResult( - exit_code=130, + exit_code=0, job_id=1, - job_uid="job12345", - duration=0.5, - inputs=[], - outputs=[{"path": "/tmp/out.txt", "size": 1, "hashes": []}], - interrupted=True, - is_build=False, + job_uid="abc12345", + duration=1.0, + inputs=[ + { + "path": "/a/in.txt", + "size": 1, + "hashes": [{"algorithm": "blake3", "digest": "d328d068abcd1234"}], + "parent_job_uid": "8dc58ec2", + }, + ], + outputs=[{"path": "/a/out.txt", "size": 1, "hashes": []}], ), - ["python", "train.py"], + [], ) - out = _strip(buf.getvalue()) - assert "roar pop" in out - assert "roar dag" not in out - assert "roar show --job job12345" in out + assert "Inputs (1)" in out + assert "Hash" in out + assert "Source Job" in out + assert "d328d068" in out + assert "8dc58ec2" in out -def test_successful_run_suggests_show_and_dag() -> None: +def test_outputs_section_no_source_job() -> None: buf = io.StringIO() report = RunReportPresenter(stream=buf, caps=_tty_caps()) - report.summary( RunResult( exit_code=0, - job_id=2, - job_uid="job67890", + job_id=1, + job_uid="abc12345", duration=1.0, inputs=[], - outputs=[], - interrupted=False, - is_build=False, + outputs=[ + { + "path": "/a/out.bin", + "size": 100, + "hashes": [{"algorithm": "blake3", "digest": "b7ad9ea41234abcd"}], + }, + ], ), - ["python", "train.py"], + [], ) - out = _strip(buf.getvalue()) - assert "roar show --job job67890" in out - assert "roar dag" in out + assert "Outputs (1)" in out + assert "b7ad9ea4" in out + assert "Source Job" not in out.split("Outputs")[1] -def test_summary_has_three_column_headers() -> None: +def test_job_section_with_git_and_env() -> None: buf = io.StringIO() report = RunReportPresenter(stream=buf, caps=_tty_caps()) - report.summary( RunResult( exit_code=0, job_id=1, - job_uid="abc12345", + job_uid="f3fba717", duration=1.0, - inputs=[{"path": "/a/in.txt", "size": 1, "hashes": []}], - outputs=[{"path": "/a/out.txt", "size": 1, "hashes": []}], - pip_count=5, + inputs=[], + outputs=[], + git_branch="main", + git_short_commit="10c570b", + git_clean=True, + pip_count=9, dpkg_count=10, env_count=3, ), - ["python", "x.py"], + [], ) out = _strip(buf.getvalue()) - assert "Inputs (1)" in out - assert "abc12345" in out # job UID in the data row - assert "Outputs (1)" in out - assert "5 pip" in out + assert "Job" in out + assert "id" in out + assert "f3fba717" in out + assert "git" in out + assert "main @ 10c570b" in out + assert "clean" in out + assert "env" in out + assert "9 pip" in out assert "10 dpkg" in out - assert "3 vars" in out - assert "Inspect:" in out + assert "3 var" in out -def test_summary_truncates_and_shows_more_indicator() -> None: +def test_dag_section() -> None: buf = io.StringIO() report = RunReportPresenter(stream=buf, caps=_tty_caps()) - # 10 inputs β€” well over the per-column cap of 4. - inputs = [{"path": f"/data/in_{i}.txt", "size": 1, "hashes": []} for i in range(10)] report.summary( RunResult( exit_code=0, job_id=1, job_uid="abc12345", duration=1.0, - inputs=inputs, + inputs=[], outputs=[], + dag_jobs=4, + dag_artifacts=1, + dag_depth=2, ), - ["python", "x.py"], + [], + ) + out = _strip(buf.getvalue()) + assert "DAG" in out + assert "4 jobs" in out + assert "1 artifact" in out # singular + assert "depth 2" in out + + +def test_inspect_section_suggests_show_and_dag() -> None: + buf = io.StringIO() + report = RunReportPresenter(stream=buf, caps=_tty_caps()) + report.summary( + RunResult(exit_code=0, job_id=1, job_uid="abc12345", duration=1.0, inputs=[], outputs=[]), + [], + ) + out = _strip(buf.getvalue()) + assert "Inspect" in out + assert "roar show --job abc12345" in out + assert "# details" in out + assert "roar dag" in out + assert "# full lineage" in out + + +def test_interrupted_run_suggests_pop() -> None: + buf = io.StringIO() + report = RunReportPresenter(stream=buf, caps=_tty_caps()) + report.summary( + RunResult( + exit_code=130, + job_id=1, + job_uid="job12345", + duration=0.5, + inputs=[], + outputs=[{"path": "/tmp/out.txt", "size": 1, "hashes": []}], + interrupted=True, + ), + [], + ) + out = _strip(buf.getvalue()) + assert "roar pop" in out + assert "roar dag" not in out + + +def test_truncation_with_more_indicator() -> None: + buf = io.StringIO() + report = RunReportPresenter(stream=buf, caps=_tty_caps()) + inputs = [{"path": f"/data/in_{i}.txt", "size": 1, "hashes": []} for i in range(10)] + report.summary( + RunResult( + exit_code=0, job_id=1, job_uid="abc12345", duration=1.0, inputs=inputs, outputs=[] + ), + [], ) out = _strip(buf.getvalue()) assert "Inputs (10)" in out - assert "and 7 more" in out + assert "and 6 more" in out # ---- quiet + pipe modes --------------------------------------------------- @@ -161,20 +222,12 @@ def test_summary_truncates_and_shows_more_indicator() -> None: def test_quiet_mode_emits_nothing() -> None: buf = io.StringIO() report = RunReportPresenter(stream=buf, caps=_tty_caps(), quiet=True) - report.trace_starting(backend="preload", proxy_active=False) report.trace_ended(duration=0.5, exit_code=0) report.lineage_captured() report.summary( - RunResult( - exit_code=0, - job_id=1, - job_uid="abc12345", - duration=0.5, - inputs=[], - outputs=[], - ), - ["python", "x.py"], + RunResult(exit_code=0, job_id=1, job_uid="abc12345", duration=0.5, inputs=[], outputs=[]), + [], ) report.done(exit_code=0, trace_duration=0.5, post_duration=0.1) assert buf.getvalue() == "" @@ -183,72 +236,75 @@ def test_quiet_mode_emits_nothing() -> None: def test_pipe_mode_emits_only_done_line() -> None: buf = io.StringIO() report = RunReportPresenter(stream=buf, caps=_pipe_caps()) - - # Lifecycle events are silent in pipe mode. report.trace_starting(backend="preload", proxy_active=False) report.trace_ended(duration=0.5, exit_code=0) report.lineage_captured() report.summary( - RunResult( - exit_code=0, - job_id=1, - job_uid="abc12345", - duration=0.5, - inputs=[], - outputs=[], - ), - ["python", "x.py"], + RunResult(exit_code=0, job_id=1, job_uid="abc12345", duration=0.5, inputs=[], outputs=[]), + [], ) report.done(exit_code=0, trace_duration=0.5, post_duration=0.1) out = buf.getvalue() - # Only the final one-liner. assert out.count("\n") == 1 assert out.startswith("roar: done") - assert "exit 0" in out -# ---- lifecycle lines ------------------------------------------------------ +# ---- lifecycle lines ------------------------------------------------------- -def test_trace_starting_uses_plain_prefix_without_emoji() -> None: +def test_trace_starting_format() -> None: buf = io.StringIO() report = RunReportPresenter(stream=buf, caps=_tty_caps()) report.trace_starting(backend="preload", proxy_active=True) out = _strip(buf.getvalue()) - assert out.startswith("roar:") assert "tracing" in out - assert "preload" in out + assert "tracer:preload" in out assert "proxy:on" in out assert "sync:off" in out -def test_trace_ended_colors_nonzero_exit() -> None: +def test_trace_ended_exit_before_duration() -> None: buf = io.StringIO() - caps = TerminalCaps(is_tty=True, can_color=True, can_emoji=False, width=80) - report = RunReportPresenter(stream=buf, caps=caps) - report.trace_ended(duration=1.5, exit_code=3) - raw = buf.getvalue() - # Red ANSI code around exit text. - assert "\x1b[31m" in raw or "\x1b[1m\x1b[31m" in raw or "exit 3" in raw - # And the plain text is still there. - assert "exit 3" in _strip(raw) + report = RunReportPresenter(stream=buf, caps=_tty_caps()) + report.trace_ended(duration=11.2, exit_code=0) + out = _strip(buf.getvalue()) + exit_pos = out.index("exit 0") + dur_pos = out.index("11.2s") + assert exit_pos < dur_pos # exit code appears before duration + + +def test_hashed_line_singular() -> None: + buf = io.StringIO() + report = RunReportPresenter(stream=buf, caps=_tty_caps()) + report.hashed(n_artifacts=1, total_bytes=1024 * 1024, duration=0.5) + out = _strip(buf.getvalue()) + assert "1 artifact" in out + assert "artifacts" not in out + assert "MB/s" in out -def test_done_shows_separate_trace_and_post_durations() -> None: +def test_done_shows_trace_and_post() -> None: buf = io.StringIO() report = RunReportPresenter(stream=buf, caps=_tty_caps()) - report.done(exit_code=0, trace_duration=1.3, post_duration=0.3) + report.done(exit_code=0, trace_duration=11.2, post_duration=0.6) out = _strip(buf.getvalue()) - assert "trace 1.3s" in out - assert "post 0.3s" in out assert "done" in out + assert "trace 11.2s" in out + assert "post 0.6s" in out + + +def test_lineage_uses_dna_emoji() -> None: + buf = io.StringIO() + caps = TerminalCaps(is_tty=True, can_color=False, can_emoji=True, width=80) + report = RunReportPresenter(stream=buf, caps=caps) + report.lineage_captured() + assert "🧬" in buf.getvalue() -# ---- legacy entry point --------------------------------------------------- +# ---- legacy one-shot ------------------------------------------------------- -def test_show_report_legacy_one_shot() -> None: - """show_report() still works β€” renders trace_ended + summary + done in a row.""" +def test_show_report_legacy() -> None: buf = io.StringIO() report = RunReportPresenter(_CapturePresenter(), stream=buf, caps=_tty_caps()) report.show_report( @@ -261,7 +317,7 @@ def test_show_report_legacy_one_shot() -> None: outputs=[], post_duration=0.2, ), - ["python", "x.py"], + [], ) out = _strip(buf.getvalue()) assert "roar show --job job12345" in out From 66f49c924723f51dd51cbc7a08954328446f4889 Mon Sep 17 00:00:00 2001 From: Chris Geyer Date: Fri, 17 Apr 2026 17:28:22 +0000 Subject: [PATCH 07/14] feat(run): show resolved tracer backend in trace-done line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trace_starting line shows the requested mode (often "auto"). The trace_ended line now appends the actual backend that was used in dim brackets: `πŸ¦– trace done [preload] exit 0 Β· 0.9s`. Co-Authored-By: Claude Opus 4.6 (1M context) --- roar/execution/runtime/coordinator.py | 8 +++++++- roar/presenters/run_report.py | 7 +++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/roar/execution/runtime/coordinator.py b/roar/execution/runtime/coordinator.py index 46ccc454..e71f852e 100644 --- a/roar/execution/runtime/coordinator.py +++ b/roar/execution/runtime/coordinator.py @@ -175,7 +175,13 @@ def stop_proxy_if_running() -> list: tracer_mode_override=ctx.tracer_mode, fallback_enabled_override=ctx.tracer_fallback, ) - run_presenter.trace_ended(tracer_result.duration, tracer_result.exit_code) + run_presenter.trace_ended( + tracer_result.duration, + tracer_result.exit_code, + backend=getattr(tracer_result, "backend", None) + if isinstance(getattr(tracer_result, "backend", None), str) + else None, + ) self.logger.debug( "Tracer completed: exit_code=%d, duration=%.2fs, interrupted=%s", tracer_result.exit_code, diff --git a/roar/presenters/run_report.py b/roar/presenters/run_report.py index 98b391c8..7582bb18 100644 --- a/roar/presenters/run_report.py +++ b/roar/presenters/run_report.py @@ -145,11 +145,14 @@ def trace_starting(self, backend: str | None, proxy_active: bool) -> None: ) self._emit_status(label, params) - def trace_ended(self, duration: float, exit_code: int) -> None: + def trace_ended(self, duration: float, exit_code: int, backend: str | None = None) -> None: if self._quiet or self._caps.pipe_mode: return c = self._caps.can_color - label = style("trace done", "bold", "status_green", enabled=c) + backend_suffix = "" + if backend: + backend_suffix = style(f" [{backend}]", "dim", enabled=c) + label = style("trace done", "bold", "status_green", enabled=c) + backend_suffix exit_s = f"exit {exit_code}" if exit_code != 0: exit_s = style(exit_s, "red", "bold", enabled=c) From b9cd9a71b7f6fa7b7bf05d09220ffa54150431a8 Mon Sep 17 00:00:00 2001 From: Chris Geyer Date: Fri, 17 Apr 2026 17:33:38 +0000 Subject: [PATCH 08/14] feat(run): align status values with hash column, variable path width - Status-line values (tracer:auto, MB/s, etc.) now start at the same column as the Hash column in Inputs/Outputs sections. - Path column is variable-width: sized to the longest filename in each section (min 14, max 30, default 20). Short filenames no longer waste space; long ones get room. - Section column headers (Hash, Source Job) aligned with data below. Co-Authored-By: Claude Opus 4.6 (1M context) --- roar/presenters/run_report.py | 42 ++++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/roar/presenters/run_report.py b/roar/presenters/run_report.py index 7582bb18..857cb0aa 100644 --- a/roar/presenters/run_report.py +++ b/roar/presenters/run_report.py @@ -34,18 +34,28 @@ # Layout constants # --------------------------------------------------------------------------- -_PATH_W = 20 # artifact-name column width _HASH_W = 8 # fixed 8-char digest _COL_GAP = 4 # spaces between hash columns _INDENT = " " # section-header indent (2 spaces) _ROW_INDENT = " " # data-row indent (4 spaces) _MAX_ROWS = 5 # max visible rows per section before "… and N more" +_MIN_PATH_W = 14 # minimum path column width +_MAX_PATH_W = 30 # maximum path column width +_DEFAULT_PATH_W = 20 # used when no paths to measure def _plural(n: int, singular: str, plural: str | None = None) -> str: return f"{n} {singular}" if n == 1 else f"{n} {plural or singular + 's'}" +def _compute_path_w(files: list[dict]) -> int: + """Compute path column width from actual filenames.""" + if not files: + return _DEFAULT_PATH_W + longest = max(len(_basename(f["path"])) for f in files) + return max(_MIN_PATH_W, min(longest + 2, _MAX_PATH_W)) + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -296,10 +306,16 @@ def _print(self, line: str = "") -> None: print(line, file=self._stream, flush=True) def _emit_status(self, label: str, value: str = "", *, emoji: str = "πŸ¦–") -> None: - """Emit a two-column status line: ``πŸ¦– label value``.""" + """Emit a two-column status line: ``πŸ¦– label value``. + + Values are padded to start at the same column as the Hash column + in the Inputs/Outputs sections (``_ROW_INDENT + path_w``). + """ prefix = self._emoji(emoji) - # Pad label to a fixed width so values align in a column. - padded_label = _pad(f"{prefix} {label}", 28 + _visible_len(prefix)) + # Target column = 4 (row indent) + default path width. Status lines + # fire before we know the actual path widths, so we use the default. + target_col = len(_ROW_INDENT) + _DEFAULT_PATH_W + padded_label = _pad(f"{prefix} {label}", target_col) self._print(f"{padded_label}{value}") def _section_header(self, title: str, col_headers: str = "") -> None: @@ -325,18 +341,26 @@ def _render_summary(self, result: RunResult) -> None: n_in = len(inputs) n_out = len(outputs) + # Compute variable path widths per section. + in_path_w = _compute_path_w(inputs) if inputs else _DEFAULT_PATH_W + out_path_w = _compute_path_w(outputs) if outputs else _DEFAULT_PATH_W + self._print() # -- Inputs section -- hash_hdr = _pad(style("Hash", "dim", enabled=c), _HASH_W + _COL_GAP) src_hdr = style("Source Job", "dim", enabled=c) count_dim = style(f" ({n_in})", "dim", enabled=c) - self._section_header(f"Inputs{count_dim}", f"{'':>{_PATH_W - 6}}{hash_hdr}{src_hdr}") + # Column headers aligned with data: data starts at _ROW_INDENT + path_w, + # section header starts at _INDENT. Offset = len(_ROW_INDENT) - len(_INDENT) + path_w - title_len. + title_text = f"Inputs ({n_in})" + col_offset = len(_ROW_INDENT) - len(_INDENT) + in_path_w - len(title_text) + self._section_header(f"Inputs{count_dim}", f"{'':>{max(1, col_offset)}}{hash_hdr}{src_hdr}") shown_in = min(n_in, _MAX_ROWS - 1) if n_in > _MAX_ROWS else n_in for i in range(shown_in): inp = inputs[i] - name = _pad(_truncate(_basename(inp["path"]), _PATH_W), _PATH_W) + name = _pad(_truncate(_basename(inp["path"]), in_path_w), in_path_w) digest = _pad(_digest8(inp), _HASH_W + _COL_GAP) parent_uid = inp.get("parent_job_uid") src = ( @@ -382,12 +406,14 @@ def _render_summary(self, result: RunResult) -> None: # -- Outputs section -- out_hash_hdr = style("Hash", "dim", enabled=c) count_dim = style(f" ({n_out})", "dim", enabled=c) - self._section_header(f"Outputs{count_dim}", f"{'':>{_PATH_W - 7}}{out_hash_hdr}") + title_text = f"Outputs ({n_out})" + col_offset = len(_ROW_INDENT) - len(_INDENT) + out_path_w - len(title_text) + self._section_header(f"Outputs{count_dim}", f"{'':>{max(1, col_offset)}}{out_hash_hdr}") shown_out = min(n_out, _MAX_ROWS - 1) if n_out > _MAX_ROWS else n_out for i in range(shown_out): out = outputs[i] - name = _pad(_truncate(_basename(out["path"]), _PATH_W), _PATH_W) + name = _pad(_truncate(_basename(out["path"]), out_path_w), out_path_w) digest = _digest8(out) self._section_row(f"{name}{digest}") if n_out > shown_out: From 3f18dccef830f32c4be64be768f0f97932bea71f Mon Sep 17 00:00:00 2001 From: Chris Geyer Date: Fri, 17 Apr 2026 18:45:57 +0000 Subject: [PATCH 09/14] fix(run): align Hash/Source Job headers with status-line values All three zones now start at the same column (_VALUE_COL = 24): - Status-line values (tracer:auto, MB/s, timing) - Section column headers (Hash, Source Job) - Data-row hashes Previously the section headers used a path_w-relative offset that drifted from the status-line padding. Co-Authored-By: Claude Opus 4.6 (1M context) --- roar/presenters/run_report.py | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/roar/presenters/run_report.py b/roar/presenters/run_report.py index 857cb0aa..ca2eb511 100644 --- a/roar/presenters/run_report.py +++ b/roar/presenters/run_report.py @@ -42,6 +42,9 @@ _MIN_PATH_W = 14 # minimum path column width _MAX_PATH_W = 30 # maximum path column width _DEFAULT_PATH_W = 20 # used when no paths to measure +# The column where values/hashes start. Status lines, section column headers, +# and data-row hashes all align to this position from the left margin. +_VALUE_COL = len(_ROW_INDENT) + _DEFAULT_PATH_W # = 24 def _plural(n: int, singular: str, plural: str | None = None) -> str: @@ -308,14 +311,11 @@ def _print(self, line: str = "") -> None: def _emit_status(self, label: str, value: str = "", *, emoji: str = "πŸ¦–") -> None: """Emit a two-column status line: ``πŸ¦– label value``. - Values are padded to start at the same column as the Hash column - in the Inputs/Outputs sections (``_ROW_INDENT + path_w``). + Values start at ``_VALUE_COL`` β€” the same column where Hash headers + and data-row hashes begin in the summary sections. """ prefix = self._emoji(emoji) - # Target column = 4 (row indent) + default path width. Status lines - # fire before we know the actual path widths, so we use the default. - target_col = len(_ROW_INDENT) + _DEFAULT_PATH_W - padded_label = _pad(f"{prefix} {label}", target_col) + padded_label = _pad(f"{prefix} {label}", _VALUE_COL) self._print(f"{padded_label}{value}") def _section_header(self, title: str, col_headers: str = "") -> None: @@ -351,16 +351,18 @@ def _render_summary(self, result: RunResult) -> None: hash_hdr = _pad(style("Hash", "dim", enabled=c), _HASH_W + _COL_GAP) src_hdr = style("Source Job", "dim", enabled=c) count_dim = style(f" ({n_in})", "dim", enabled=c) - # Column headers aligned with data: data starts at _ROW_INDENT + path_w, - # section header starts at _INDENT. Offset = len(_ROW_INDENT) - len(_INDENT) + path_w - title_len. + # Align "Hash" at _VALUE_COL. Section header starts at _INDENT (2). title_text = f"Inputs ({n_in})" - col_offset = len(_ROW_INDENT) - len(_INDENT) + in_path_w - len(title_text) + col_offset = _VALUE_COL - len(_INDENT) - len(title_text) self._section_header(f"Inputs{count_dim}", f"{'':>{max(1, col_offset)}}{hash_hdr}{src_hdr}") + # Data-row path padding: at least enough to place hash at _VALUE_COL. + in_pad_w = max(in_path_w, _VALUE_COL - len(_ROW_INDENT)) + shown_in = min(n_in, _MAX_ROWS - 1) if n_in > _MAX_ROWS else n_in for i in range(shown_in): inp = inputs[i] - name = _pad(_truncate(_basename(inp["path"]), in_path_w), in_path_w) + name = _pad(_truncate(_basename(inp["path"]), in_path_w), in_pad_w) digest = _pad(_digest8(inp), _HASH_W + _COL_GAP) parent_uid = inp.get("parent_job_uid") src = ( @@ -407,13 +409,15 @@ def _render_summary(self, result: RunResult) -> None: out_hash_hdr = style("Hash", "dim", enabled=c) count_dim = style(f" ({n_out})", "dim", enabled=c) title_text = f"Outputs ({n_out})" - col_offset = len(_ROW_INDENT) - len(_INDENT) + out_path_w - len(title_text) + col_offset = _VALUE_COL - len(_INDENT) - len(title_text) self._section_header(f"Outputs{count_dim}", f"{'':>{max(1, col_offset)}}{out_hash_hdr}") + out_pad_w = max(out_path_w, _VALUE_COL - len(_ROW_INDENT)) + shown_out = min(n_out, _MAX_ROWS - 1) if n_out > _MAX_ROWS else n_out for i in range(shown_out): out = outputs[i] - name = _pad(_truncate(_basename(out["path"]), out_path_w), out_path_w) + name = _pad(_truncate(_basename(out["path"]), out_path_w), out_pad_w) digest = _digest8(out) self._section_row(f"{name}{digest}") if n_out > shown_out: From 4269a286a875a160f0b87c0273cbe20a8bc3fc88 Mon Sep 17 00:00:00 2001 From: Chris Geyer Date: Fri, 17 Apr 2026 18:55:23 +0000 Subject: [PATCH 10/14] =?UTF-8?q?fix(run):=20use=20=F0=9F=A6=96=20consiste?= =?UTF-8?q?ntly,=20fix=20emoji=20width,=20align=20#=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use πŸ¦– for all status lines. Drops πŸ«† (Emoji 15.1, 2024 β€” broken in tmux and older terminals) and 🧬. Consistent branding, no compat issues. - Account for emoji being 2 display cells in padding calculation so status values align correctly regardless of emoji/non-emoji mode. - Align # comments in Inspect section at the same column by padding commands to uniform width. Co-Authored-By: Claude Opus 4.6 (1M context) --- roar/presenters/run_report.py | 45 ++++++++++++++++++++--------------- tests/unit/test_run_report.py | 4 ++-- 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/roar/presenters/run_report.py b/roar/presenters/run_report.py index ca2eb511..0638fd4e 100644 --- a/roar/presenters/run_report.py +++ b/roar/presenters/run_report.py @@ -180,7 +180,7 @@ def hashing(self, total: int | None = None): yield _NullProgress() return c = self._caps.can_color - prefix = self._emoji("πŸ¦–") + " " + prefix = "πŸ¦– " if self._caps.can_emoji else "roar: " label = style("hashing", "bold", "status_green", enabled=c) if total: label += style(f" ({_plural(total, 'artifact')})", "dim", enabled=c) @@ -203,14 +203,14 @@ def hashed(self, n_artifacts: int, total_bytes: int, duration: float) -> None: tp = style(f"{mbps:.1f} MB/s", "dim", enabled=c) else: tp = "" - self._emit_status(label, tp, emoji="πŸ«†") + self._emit_status(label, tp) def lineage_captured(self) -> None: if self._quiet or self._caps.pipe_mode: return c = self._caps.can_color label = style("lineage captured", "bold", "status_green", enabled=c) - self._emit_status(label, emoji="🧬") + self._emit_status(label) def summary(self, result: RunResult, command: list[str]) -> None: if self._quiet or self._caps.pipe_mode: @@ -302,21 +302,24 @@ def show_upstream_stale_warning(self, step_num: int, upstream_stale: list[int]) # ---- internal: output primitives ------------------------------------- - def _emoji(self, char: str) -> str: - return char if self._caps.can_emoji else "roar:" - def _print(self, line: str = "") -> None: print(line, file=self._stream, flush=True) - def _emit_status(self, label: str, value: str = "", *, emoji: str = "πŸ¦–") -> None: + def _emit_status(self, label: str, value: str = "") -> None: """Emit a two-column status line: ``πŸ¦– label value``. Values start at ``_VALUE_COL`` β€” the same column where Hash headers and data-row hashes begin in the summary sections. """ - prefix = self._emoji(emoji) - padded_label = _pad(f"{prefix} {label}", _VALUE_COL) - self._print(f"{padded_label}{value}") + if self._caps.can_emoji: + # Emoji is 2 display cells; "πŸ¦– " = 3 cells. _pad sees the + # raw codepoint as 1 char, so we subtract 1 from the target to + # compensate for the extra display cell. + raw = f"πŸ¦– {label}" + padded = _pad(raw, _VALUE_COL - 1) + else: + padded = _pad(f"roar: {label}", _VALUE_COL) + self._print(f"{padded}{value}") def _section_header(self, title: str, col_headers: str = "") -> None: c = self._caps.can_color @@ -441,16 +444,20 @@ def _render_summary(self, result: RunResult) -> None: # -- Inspect section -- self._section_header("Inspect") - show_cmd = style(f"roar show --job {result.job_uid}", "command_blue", enabled=c) - show_comment = style(" # details", "dim", enabled=c) - self._section_row(f"{show_cmd}{show_comment}") + # Align # comments at the same column. + show_text = f"roar show --job {result.job_uid}" if result.interrupted and result.outputs: - pop_cmd = style("roar pop", "command_blue", enabled=c) - pop_comment = style(" # undo interrupted run", "dim", enabled=c) - self._section_row(f"{pop_cmd}{pop_comment}") + alt_text = "roar pop" + alt_comment = "# undo interrupted run" else: - dag_cmd = style("roar dag", "command_blue", enabled=c) - dag_comment = style(" # full lineage", "dim", enabled=c) - self._section_row(f"{dag_cmd}{dag_comment}") + alt_text = "roar dag" + alt_comment = "# full lineage" + cmd_w = max(len(show_text), len(alt_text)) + 4 # 4-space gap before # + show_cmd = _pad(style(show_text, "command_blue", enabled=c), cmd_w) + show_comment = style("# details", "dim", enabled=c) + self._section_row(f"{show_cmd}{show_comment}") + alt_cmd = _pad(style(alt_text, "command_blue", enabled=c), cmd_w) + alt_cmt = style(alt_comment, "dim", enabled=c) + self._section_row(f"{alt_cmd}{alt_cmt}") self._print() diff --git a/tests/unit/test_run_report.py b/tests/unit/test_run_report.py index 3e1a2dcc..999e9abb 100644 --- a/tests/unit/test_run_report.py +++ b/tests/unit/test_run_report.py @@ -293,12 +293,12 @@ def test_done_shows_trace_and_post() -> None: assert "post 0.6s" in out -def test_lineage_uses_dna_emoji() -> None: +def test_lineage_uses_trex_emoji() -> None: buf = io.StringIO() caps = TerminalCaps(is_tty=True, can_color=False, can_emoji=True, width=80) report = RunReportPresenter(stream=buf, caps=caps) report.lineage_captured() - assert "🧬" in buf.getvalue() + assert "πŸ¦–" in buf.getvalue() # ---- legacy one-shot ------------------------------------------------------- From 952b9266c3df9b29f7517298acd9fd877123dcdd Mon Sep 17 00:00:00 2001 From: Chris Geyer Date: Fri, 17 Apr 2026 19:27:47 +0000 Subject: [PATCH 11/14] feat(run): minimalist narration-style output (v8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the section-based layout with a compact, git-like narration where every line is voiced by πŸ¦– or prefixed with Β· (detail block). Format: πŸ¦– tracing Β· tracer:preload proxy:off sync:off πŸ¦– trace done [preload] Β· 0.9s Β· exit 0 πŸ¦– hashed 5 artifacts Β· 204.9 MB/s πŸ¦– lineage captured: Β· i/o 2 inputs ← 1 prior job Β· 3 outputs Β· job 32156d79 Β· git master @ efc9a23 Β· clean Β· env 9 pip Β· 10 dpkg Β· 2 vars Β· dag 1 job Β· 3 artifacts Β· depth 1 Β· Β· $ roar show --job 32156d79 # details πŸ¦– done Β· trace 0.9s + post 0.6s Key changes from v7: - No section headers, column headers, or per-artifact rows - Summary is counts only: i/o (with prior-job count), job (bold), git, env, dag β€” each on a Β· detail line with 3-char label - Hashing spinner is transient (skipped for < 5 artifacts) - Suggested command uses $ prefix ("paste this") - pip/dpkg stay uncountable ("9 pip" not "9 pips") - warn_amber (256 #172) for dirty git and non-zero exit - Β· (middle dot U+00B7) as sole separator everywhere Co-Authored-By: Claude Opus 4.6 (1M context) --- roar/presenters/run_report.py | 336 +++++++++++----------------------- roar/presenters/terminal.py | 1 + tests/unit/test_run_report.py | 283 +++++++++++----------------- 3 files changed, 213 insertions(+), 407 deletions(-) diff --git a/roar/presenters/run_report.py b/roar/presenters/run_report.py index 0638fd4e..0c05a482 100644 --- a/roar/presenters/run_report.py +++ b/roar/presenters/run_report.py @@ -1,25 +1,18 @@ -"""Run report presenter β€” v7 section-based layout. - -Renders the lifecycle of ``roar run`` as status lines followed by five -distinct sections (Inputs, Job, Outputs, DAG, Inspect). All output -goes to stderr so the user command's stdout remains clean for piping. - -Color tokens (via ``terminal.style``): - status_green -status lines, section headers (bold) - command_blue -actionable commands in Inspect block - dim -column headers, metadata labels, source-job hashes, - counts in parentheses, comments, timing breakdown - bold -current job hash (emphasis, no hue) - -Hashes carry NO hue. Weight alone distinguishes them: - bold -the current job hash (once, in the Job block) - regular -artifact hashes in Inputs / Outputs - dim -source-job hashes (context) +"""Run report presenter - minimalist narration-style output. + +Every status line is narrated by πŸ¦–. The lineage detail block uses +``Β·`` (middle dot) as a line prefix with 3-char category labels. + +Color tokens (all in terminal.py, no raw ANSI here): + status_green - πŸ¦– lines, ``exit 0``, ``clean`` + warn_amber - ``dirty`` git, non-zero exit + command_blue - suggested command text + dim - prefixes, labels, flags, comments, timing + bold - current job hash (no hue) """ from __future__ import annotations -import os import re import sys from contextlib import contextmanager @@ -31,34 +24,17 @@ from .terminal import TerminalCaps, detect, style # --------------------------------------------------------------------------- -# Layout constants +# Constants # --------------------------------------------------------------------------- -_HASH_W = 8 # fixed 8-char digest -_COL_GAP = 4 # spaces between hash columns -_INDENT = " " # section-header indent (2 spaces) -_ROW_INDENT = " " # data-row indent (4 spaces) -_MAX_ROWS = 5 # max visible rows per section before "… and N more" -_MIN_PATH_W = 14 # minimum path column width -_MAX_PATH_W = 30 # maximum path column width -_DEFAULT_PATH_W = 20 # used when no paths to measure -# The column where values/hashes start. Status lines, section column headers, -# and data-row hashes all align to this position from the left margin. -_VALUE_COL = len(_ROW_INDENT) + _DEFAULT_PATH_W # = 24 +_HASH_W = 8 +_SMALL_RUN = 5 # skip transient hashing progress below this count def _plural(n: int, singular: str, plural: str | None = None) -> str: return f"{n} {singular}" if n == 1 else f"{n} {plural or singular + 's'}" -def _compute_path_w(files: list[dict]) -> int: - """Compute path column width from actual filenames.""" - if not files: - return _DEFAULT_PATH_W - longest = max(len(_basename(f["path"])) for f in files) - return max(_MIN_PATH_W, min(longest + 2, _MAX_PATH_W)) - - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -75,31 +51,7 @@ def _pad(text: str, width: int) -> str: return text + " " * max(0, width - vis) -def _truncate(text: str, width: int) -> str: - if len(text) <= width: - return text - return text[: max(1, width - 1)] + "…" - - -def _basename(path: str) -> str: - try: - rel = os.path.relpath(path) - if not rel.startswith(".."): - return rel - except ValueError: - pass - return os.path.basename(path) or path - - -def _digest8(file_info: dict) -> str: - hashes = file_info.get("hashes", []) - if not hashes: - return "" - return hashes[0].get("digest", "")[:_HASH_W] - - def format_size(size_bytes: int | None) -> str: - """Format file size in human-readable format.""" if size_bytes is None: return "?" size: float = float(size_bytes) @@ -111,8 +63,6 @@ def format_size(size_bytes: int | None) -> str: class _NullProgress: - """No-op stand-in returned by ``hashing()`` when in pipe/quiet mode.""" - def advance(self, delta: int = 1) -> None: pass @@ -129,7 +79,7 @@ def update(self, message: str) -> None: class RunReportPresenter: - """Formats and displays the ``roar run`` lifecycle and summary.""" + """Minimalist narration-style output for ``roar run``.""" def __init__( self, @@ -150,100 +100,81 @@ def trace_starting(self, backend: str | None, proxy_active: bool) -> None: if self._quiet or self._caps.pipe_mode: return c = self._caps.can_color - label = style("tracing", "bold", "status_green", enabled=c) - params = style( - f"tracer:{backend or 'auto'} proxy:{'on' if proxy_active else 'off'} sync:off", + flags = style( + f"tracer:{backend or 'auto'} proxy:{'on' if proxy_active else 'off'} sync:off", "dim", enabled=c, ) - self._emit_status(label, params) + self._trex(f"tracing {self._dim_sep()}{flags}") def trace_ended(self, duration: float, exit_code: int, backend: str | None = None) -> None: if self._quiet or self._caps.pipe_mode: return c = self._caps.can_color - backend_suffix = "" + parts = ["trace done"] if backend: - backend_suffix = style(f" [{backend}]", "dim", enabled=c) - label = style("trace done", "bold", "status_green", enabled=c) + backend_suffix + parts[0] += style(f" [{backend}]", "dim", enabled=c) + parts.append(self._fmt_dur(duration)) exit_s = f"exit {exit_code}" - if exit_code != 0: - exit_s = style(exit_s, "red", "bold", enabled=c) - else: + if exit_code == 0: exit_s = style(exit_s, "status_green", enabled=c) - dur_s = style(f"Β· {self._fmt_dur(duration)}", "dim", enabled=c) - self._emit_status(label, f"{exit_s} {dur_s}") + else: + exit_s = style(exit_s, "warn_amber", "bold", enabled=c) + parts.append(exit_s) + self._trex(f" {self._dim_sep()}".join(parts)) @contextmanager def hashing(self, total: int | None = None): if self._quiet or self._caps.pipe_mode: yield _NullProgress() return - c = self._caps.can_color + # Skip transient spinner for small runs. + if total is not None and total < _SMALL_RUN: + yield _NullProgress() + return prefix = "πŸ¦– " if self._caps.can_emoji else "roar: " - label = style("hashing", "bold", "status_green", enabled=c) - if total: - label += style(f" ({_plural(total, 'artifact')})", "dim", enabled=c) frames = CLOCK_FRAMES if self._caps.can_emoji else BRAILLE_FRAMES - with Spinner(label, prefix=prefix, frames=frames, interval=0.1) as sp: + with Spinner("hashing", prefix=prefix, frames=frames, interval=0.1) as sp: yield sp def hashed(self, n_artifacts: int, total_bytes: int, duration: float) -> None: if self._quiet or self._caps.pipe_mode: return c = self._caps.can_color - label = style( - f"hashed {_plural(n_artifacts, 'artifact')}", - "bold", - "status_green", - enabled=c, - ) + text = f"hashed {_plural(n_artifacts, 'artifact')}" if duration > 0 and total_bytes > 0: mbps = (total_bytes / 1024 / 1024) / duration - tp = style(f"{mbps:.1f} MB/s", "dim", enabled=c) - else: - tp = "" - self._emit_status(label, tp) + text += f" {self._dim_sep()}{style(f'{mbps:.1f} MB/s', 'dim', enabled=c)}" + self._trex(text) def lineage_captured(self) -> None: if self._quiet or self._caps.pipe_mode: return - c = self._caps.can_color - label = style("lineage captured", "bold", "status_green", enabled=c) - self._emit_status(label) + self._trex("lineage captured:") def summary(self, result: RunResult, command: list[str]) -> None: if self._quiet or self._caps.pipe_mode: return self._render_summary(result) - def done( - self, - *, - exit_code: int, - trace_duration: float, - post_duration: float, - ) -> None: + def done(self, *, exit_code: int, trace_duration: float, post_duration: float) -> None: if self._quiet: return - verb = "done" if exit_code == 0 else "failed" if self._caps.pipe_mode: total = trace_duration + post_duration - line = ( - f"roar: {verb} Β· {self._fmt_dur(total)} " + self._print( + f"roar: done Β· {self._fmt_dur(total)} " f"(trace {self._fmt_dur(trace_duration)} + " f"post {self._fmt_dur(post_duration)}, exit {exit_code})" ) - self._print(line) return c = self._caps.can_color - label = style(verb, "bold", "status_green" if exit_code == 0 else "red", enabled=c) - breakdown = style( - f"(trace {self._fmt_dur(trace_duration)} + post {self._fmt_dur(post_duration)})", + timing = style( + f"trace {self._fmt_dur(trace_duration)} + post {self._fmt_dur(post_duration)}", "dim", enabled=c, ) - self._emit_status(label, breakdown) + self._trex(f"done {self._dim_sep()}{timing}") # ---- backward-compat one-shot ---------------------------------------- @@ -300,137 +231,88 @@ def show_upstream_stale_warning(self, step_num: int, upstream_stale: list[int]) self._out.print("") return self._out.confirm("Run anyway?", default=False) - # ---- internal: output primitives ------------------------------------- + # ---- internal -------------------------------------------------------- def _print(self, line: str = "") -> None: print(line, file=self._stream, flush=True) - def _emit_status(self, label: str, value: str = "") -> None: - """Emit a two-column status line: ``πŸ¦– label value``. - - Values start at ``_VALUE_COL`` β€” the same column where Hash headers - and data-row hashes begin in the summary sections. - """ - if self._caps.can_emoji: - # Emoji is 2 display cells; "πŸ¦– " = 3 cells. _pad sees the - # raw codepoint as 1 char, so we subtract 1 from the target to - # compensate for the extra display cell. - raw = f"πŸ¦– {label}" - padded = _pad(raw, _VALUE_COL - 1) - else: - padded = _pad(f"roar: {label}", _VALUE_COL) - self._print(f"{padded}{value}") + def _trex(self, text: str) -> None: + """Emit a πŸ¦–-prefixed status line in STATUS_GREEN.""" + c = self._caps.can_color + prefix = "πŸ¦–" if self._caps.can_emoji else "roar:" + self._print( + f"{style(prefix, 'status_green', enabled=c)} {style(text, 'status_green', enabled=c)}" + ) - def _section_header(self, title: str, col_headers: str = "") -> None: + def _detail(self, label: str, content: str) -> None: + """Emit a ``Β· label content`` detail line.""" c = self._caps.can_color - styled = style(title, "bold", "status_green", enabled=c) - self._print(f"{_INDENT}{styled}{col_headers}") + prefix = style("Β·", "dim", enabled=c) + lbl = style(f"{label:<3}", "dim", enabled=c) + self._print(f"{prefix} {lbl} {content}") - def _section_row(self, text: str) -> None: - self._print(f"{_ROW_INDENT}{text}") + def _detail_blank(self) -> None: + c = self._caps.can_color + self._print(style("Β·", "dim", enabled=c)) + + def _dim_sep(self) -> str: + return style("Β· ", "dim", enabled=self._caps.can_color) def _fmt_dur(self, seconds: float) -> str: if seconds < 0.1: return f"{max(1, round(seconds * 1000))}ms" return f"{seconds:.1f}s" - # ---- internal: summary block ----------------------------------------- + # ---- summary block --------------------------------------------------- def _render_summary(self, result: RunResult) -> None: c = self._caps.can_color - inputs = result.inputs - outputs = result.outputs - n_in = len(inputs) - n_out = len(outputs) - - # Compute variable path widths per section. - in_path_w = _compute_path_w(inputs) if inputs else _DEFAULT_PATH_W - out_path_w = _compute_path_w(outputs) if outputs else _DEFAULT_PATH_W - - self._print() - - # -- Inputs section -- - hash_hdr = _pad(style("Hash", "dim", enabled=c), _HASH_W + _COL_GAP) - src_hdr = style("Source Job", "dim", enabled=c) - count_dim = style(f" ({n_in})", "dim", enabled=c) - # Align "Hash" at _VALUE_COL. Section header starts at _INDENT (2). - title_text = f"Inputs ({n_in})" - col_offset = _VALUE_COL - len(_INDENT) - len(title_text) - self._section_header(f"Inputs{count_dim}", f"{'':>{max(1, col_offset)}}{hash_hdr}{src_hdr}") - - # Data-row path padding: at least enough to place hash at _VALUE_COL. - in_pad_w = max(in_path_w, _VALUE_COL - len(_ROW_INDENT)) - - shown_in = min(n_in, _MAX_ROWS - 1) if n_in > _MAX_ROWS else n_in - for i in range(shown_in): - inp = inputs[i] - name = _pad(_truncate(_basename(inp["path"]), in_path_w), in_pad_w) - digest = _pad(_digest8(inp), _HASH_W + _COL_GAP) - parent_uid = inp.get("parent_job_uid") - src = ( - style(str(parent_uid)[:_HASH_W], "dim", enabled=c) - if parent_uid - else style("--", "dim", enabled=c) - ) - self._section_row(f"{name}{digest}{src}") - if n_in > shown_in: - self._section_row(style(f"… and {n_in - shown_in} more", "dim", "italic", enabled=c)) - - self._print() - - # -- Job section -- - self._section_header("Job") - job_id = style(result.job_uid, "bold", enabled=c) - id_label = style("id ", "dim", enabled=c) - self._section_row(f"{id_label} {job_id}") + # i/o line: "2 inputs ← 2 prior jobs Β· 1 output" + n_in = len(result.inputs) + n_out = len(result.outputs) + io_parts = [] + if n_in: + in_text = _plural(n_in, "input") + # Count unique prior (source) jobs. + source_jobs = { + inp.get("parent_job_uid") for inp in result.inputs if inp.get("parent_job_uid") + } + if source_jobs: + in_text += f" ← {_plural(len(source_jobs), 'prior job')}" + io_parts.append(in_text) + if n_out: + io_parts.append(_plural(n_out, "output")) + if io_parts: + self._detail("i/o", f" {self._dim_sep()}".join(io_parts)) + + # job line β€” bold hash, no hue. + job_hash = style(result.job_uid, "bold", enabled=c) + self._detail("job", job_hash) + + # git line. if result.git_branch or result.git_short_commit: branch = result.git_branch or "?" commit = result.git_short_commit or "?" - clean_s = "clean" if result.git_clean else "dirty" if result.git_clean: - clean_s = style(clean_s, "status_green", enabled=c) + state = style("clean", "status_green", enabled=c) else: - clean_s = style(clean_s, "red", enabled=c) - git_label = style("git", "dim", enabled=c) - self._section_row(f"{git_label} {branch} @ {commit} {clean_s}") + state = style("dirty", "warn_amber", enabled=c) + self._detail("git", f"{branch} @ {commit} {self._dim_sep()}{state}") + + # env line β€” pip/dpkg/vars are category labels, not countable nouns. env_parts = [] if result.pip_count: - env_parts.append(_plural(result.pip_count, "pip")) + env_parts.append(f"{result.pip_count} pip") if result.dpkg_count: - env_parts.append(_plural(result.dpkg_count, "dpkg")) + env_parts.append(f"{result.dpkg_count} dpkg") if result.env_count: env_parts.append(_plural(result.env_count, "var")) if env_parts: - env_label = style("env", "dim", enabled=c) - env_val = style(" Β· ".join(env_parts), "dim", enabled=c) - self._section_row(f"{env_label} {env_val}") - - self._print() - - # -- Outputs section -- - out_hash_hdr = style("Hash", "dim", enabled=c) - count_dim = style(f" ({n_out})", "dim", enabled=c) - title_text = f"Outputs ({n_out})" - col_offset = _VALUE_COL - len(_INDENT) - len(title_text) - self._section_header(f"Outputs{count_dim}", f"{'':>{max(1, col_offset)}}{out_hash_hdr}") + self._detail("env", f" {self._dim_sep()}".join(env_parts)) - out_pad_w = max(out_path_w, _VALUE_COL - len(_ROW_INDENT)) - - shown_out = min(n_out, _MAX_ROWS - 1) if n_out > _MAX_ROWS else n_out - for i in range(shown_out): - out = outputs[i] - name = _pad(_truncate(_basename(out["path"]), out_path_w), out_pad_w) - digest = _digest8(out) - self._section_row(f"{name}{digest}") - if n_out > shown_out: - self._section_row(style(f"… and {n_out - shown_out} more", "dim", "italic", enabled=c)) - - self._print() - - # -- DAG section -- + # dag line. if result.dag_jobs or result.dag_artifacts: - self._section_header("DAG") dag_parts = [] if result.dag_jobs: dag_parts.append(_plural(result.dag_jobs, "job")) @@ -438,26 +320,12 @@ def _render_summary(self, result: RunResult) -> None: dag_parts.append(_plural(result.dag_artifacts, "artifact")) if result.dag_depth: dag_parts.append(f"depth {result.dag_depth}") - dag_val = style(" Β· ".join(dag_parts), "dim", enabled=c) - self._section_row(dag_val) - self._print() - - # -- Inspect section -- - self._section_header("Inspect") - # Align # comments at the same column. - show_text = f"roar show --job {result.job_uid}" - if result.interrupted and result.outputs: - alt_text = "roar pop" - alt_comment = "# undo interrupted run" - else: - alt_text = "roar dag" - alt_comment = "# full lineage" - cmd_w = max(len(show_text), len(alt_text)) + 4 # 4-space gap before # - show_cmd = _pad(style(show_text, "command_blue", enabled=c), cmd_w) - show_comment = style("# details", "dim", enabled=c) - self._section_row(f"{show_cmd}{show_comment}") - alt_cmd = _pad(style(alt_text, "command_blue", enabled=c), cmd_w) - alt_cmt = style(alt_comment, "dim", enabled=c) - self._section_row(f"{alt_cmd}{alt_cmt}") - - self._print() + self._detail("dag", f" {self._dim_sep()}".join(dag_parts)) + + # Blank separator + suggested command. + self._detail_blank() + cmd_text = style(f"roar show --job {result.job_uid}", "command_blue", enabled=c) + comment = style("# details", "dim", enabled=c) + self._print( + f"{style('Β·', 'dim', enabled=c)} {style('$', 'dim', enabled=c)} {cmd_text} {comment}" + ) diff --git a/roar/presenters/terminal.py b/roar/presenters/terminal.py index 8740e5b8..0634920d 100644 --- a/roar/presenters/terminal.py +++ b/roar/presenters/terminal.py @@ -68,6 +68,7 @@ def detect(stream: IO | None = None) -> TerminalCaps: # Named semantic tokens β€” 256-color palette entries that read well on # both dark and light terminal backgrounds. "status_green": "\033[38;5;35m", # ANSI-256 #35 β€” muted green + "warn_amber": "\033[38;5;172m", # ANSI-256 #172 β€” amber/orange "command_blue": "\033[38;5;74m", # ANSI-256 #74 β€” steel blue } diff --git a/tests/unit/test_run_report.py b/tests/unit/test_run_report.py index 999e9abb..82460a58 100644 --- a/tests/unit/test_run_report.py +++ b/tests/unit/test_run_report.py @@ -1,4 +1,4 @@ -"""Tests for RunReportPresenter β€” v7 section-based layout.""" +"""Tests for RunReportPresenter β€” minimalist narration-style output.""" from __future__ import annotations @@ -51,229 +51,158 @@ def confirm(self, message: str, default: bool = False) -> bool: return default -# ---- section layout ------------------------------------------------------- +def _make_result(**overrides: Any) -> RunResult: + defaults: dict[str, Any] = { + "exit_code": 0, + "job_id": 1, + "job_uid": "f3fba717", + "duration": 1.0, + "inputs": [], + "outputs": [], + } + defaults.update(overrides) + return RunResult(**defaults) -def test_inputs_section_with_source_job() -> None: +# ---- summary detail lines ------------------------------------------------- + + +def test_io_line_counts_inputs_outputs_and_prior_jobs() -> None: buf = io.StringIO() report = RunReportPresenter(stream=buf, caps=_tty_caps()) report.summary( - RunResult( - exit_code=0, - job_id=1, - job_uid="abc12345", - duration=1.0, + _make_result( inputs=[ - { - "path": "/a/in.txt", - "size": 1, - "hashes": [{"algorithm": "blake3", "digest": "d328d068abcd1234"}], - "parent_job_uid": "8dc58ec2", - }, + {"path": "/a/in1.txt", "hashes": [], "parent_job_uid": "aaa11111"}, + {"path": "/a/in2.txt", "hashes": [], "parent_job_uid": "bbb22222"}, ], - outputs=[{"path": "/a/out.txt", "size": 1, "hashes": []}], + outputs=[{"path": "/a/out.txt", "hashes": []}], ), [], ) out = _strip(buf.getvalue()) - assert "Inputs (1)" in out - assert "Hash" in out - assert "Source Job" in out - assert "d328d068" in out - assert "8dc58ec2" in out + assert "i/o" in out + assert "2 inputs" in out + assert "2 prior jobs" in out + assert "1 output" in out -def test_outputs_section_no_source_job() -> None: +def test_io_line_omits_prior_jobs_when_none() -> None: buf = io.StringIO() report = RunReportPresenter(stream=buf, caps=_tty_caps()) report.summary( - RunResult( - exit_code=0, - job_id=1, - job_uid="abc12345", - duration=1.0, - inputs=[], - outputs=[ - { - "path": "/a/out.bin", - "size": 100, - "hashes": [{"algorithm": "blake3", "digest": "b7ad9ea41234abcd"}], - }, - ], - ), + _make_result(inputs=[{"path": "/a/in.txt", "hashes": []}], outputs=[]), [], ) out = _strip(buf.getvalue()) - assert "Outputs (1)" in out - assert "b7ad9ea4" in out - assert "Source Job" not in out.split("Outputs")[1] + assert "1 input" in out + assert "prior" not in out -def test_job_section_with_git_and_env() -> None: +def test_job_line_shows_bold_hash() -> None: + buf = io.StringIO() + caps = TerminalCaps(is_tty=True, can_color=True, can_emoji=False, width=80) + report = RunReportPresenter(stream=buf, caps=caps) + report.summary(_make_result(job_uid="f3fba717"), []) + raw = buf.getvalue() + assert "f3fba717" in _strip(raw) + # Bold ANSI code wraps the hash. + assert "\x1b[1m" in raw + + +def test_git_line_clean() -> None: buf = io.StringIO() report = RunReportPresenter(stream=buf, caps=_tty_caps()) report.summary( - RunResult( - exit_code=0, - job_id=1, - job_uid="f3fba717", - duration=1.0, - inputs=[], - outputs=[], - git_branch="main", - git_short_commit="10c570b", - git_clean=True, - pip_count=9, - dpkg_count=10, - env_count=3, - ), + _make_result(git_branch="main", git_short_commit="10c570b", git_clean=True), [], ) out = _strip(buf.getvalue()) - assert "Job" in out - assert "id" in out - assert "f3fba717" in out assert "git" in out assert "main @ 10c570b" in out assert "clean" in out - assert "env" in out - assert "9 pip" in out - assert "10 dpkg" in out - assert "3 var" in out -def test_dag_section() -> None: +def test_git_line_dirty_uses_amber() -> None: buf = io.StringIO() - report = RunReportPresenter(stream=buf, caps=_tty_caps()) + caps = TerminalCaps(is_tty=True, can_color=True, can_emoji=False, width=80) + report = RunReportPresenter(stream=buf, caps=caps) report.summary( - RunResult( - exit_code=0, - job_id=1, - job_uid="abc12345", - duration=1.0, - inputs=[], - outputs=[], - dag_jobs=4, - dag_artifacts=1, - dag_depth=2, - ), + _make_result(git_branch="main", git_short_commit="abc", git_clean=False), [], ) - out = _strip(buf.getvalue()) - assert "DAG" in out - assert "4 jobs" in out - assert "1 artifact" in out # singular - assert "depth 2" in out + raw = buf.getvalue() + assert "dirty" in _strip(raw) + # warn_amber = ANSI 256-color 172 + assert "\x1b[38;5;172m" in raw -def test_inspect_section_suggests_show_and_dag() -> None: +def test_env_line() -> None: buf = io.StringIO() report = RunReportPresenter(stream=buf, caps=_tty_caps()) - report.summary( - RunResult(exit_code=0, job_id=1, job_uid="abc12345", duration=1.0, inputs=[], outputs=[]), - [], - ) + report.summary(_make_result(pip_count=9, dpkg_count=10, env_count=3), []) out = _strip(buf.getvalue()) - assert "Inspect" in out - assert "roar show --job abc12345" in out - assert "# details" in out - assert "roar dag" in out - assert "# full lineage" in out + assert "env" in out + assert "9 pip" in out + assert "10 dpkg" in out + assert "3 var" in out -def test_interrupted_run_suggests_pop() -> None: +def test_dag_line_singular() -> None: buf = io.StringIO() report = RunReportPresenter(stream=buf, caps=_tty_caps()) - report.summary( - RunResult( - exit_code=130, - job_id=1, - job_uid="job12345", - duration=0.5, - inputs=[], - outputs=[{"path": "/tmp/out.txt", "size": 1, "hashes": []}], - interrupted=True, - ), - [], - ) + report.summary(_make_result(dag_jobs=1, dag_artifacts=1, dag_depth=1), []) out = _strip(buf.getvalue()) - assert "roar pop" in out - assert "roar dag" not in out + assert "1 job" in out + assert "1 artifact" in out + assert "artifacts" not in out -def test_truncation_with_more_indicator() -> None: +def test_suggested_command() -> None: buf = io.StringIO() report = RunReportPresenter(stream=buf, caps=_tty_caps()) - inputs = [{"path": f"/data/in_{i}.txt", "size": 1, "hashes": []} for i in range(10)] - report.summary( - RunResult( - exit_code=0, job_id=1, job_uid="abc12345", duration=1.0, inputs=inputs, outputs=[] - ), - [], - ) + report.summary(_make_result(job_uid="f3fba717"), []) out = _strip(buf.getvalue()) - assert "Inputs (10)" in out - assert "and 6 more" in out - - -# ---- quiet + pipe modes --------------------------------------------------- - - -def test_quiet_mode_emits_nothing() -> None: - buf = io.StringIO() - report = RunReportPresenter(stream=buf, caps=_tty_caps(), quiet=True) - report.trace_starting(backend="preload", proxy_active=False) - report.trace_ended(duration=0.5, exit_code=0) - report.lineage_captured() - report.summary( - RunResult(exit_code=0, job_id=1, job_uid="abc12345", duration=0.5, inputs=[], outputs=[]), - [], - ) - report.done(exit_code=0, trace_duration=0.5, post_duration=0.1) - assert buf.getvalue() == "" - - -def test_pipe_mode_emits_only_done_line() -> None: - buf = io.StringIO() - report = RunReportPresenter(stream=buf, caps=_pipe_caps()) - report.trace_starting(backend="preload", proxy_active=False) - report.trace_ended(duration=0.5, exit_code=0) - report.lineage_captured() - report.summary( - RunResult(exit_code=0, job_id=1, job_uid="abc12345", duration=0.5, inputs=[], outputs=[]), - [], - ) - report.done(exit_code=0, trace_duration=0.5, post_duration=0.1) - out = buf.getvalue() - assert out.count("\n") == 1 - assert out.startswith("roar: done") + assert "$ roar show --job f3fba717" in out + assert "# details" in out # ---- lifecycle lines ------------------------------------------------------- -def test_trace_starting_format() -> None: +def test_trace_starting() -> None: buf = io.StringIO() report = RunReportPresenter(stream=buf, caps=_tty_caps()) - report.trace_starting(backend="preload", proxy_active=True) + report.trace_starting(backend="preload", proxy_active=False) out = _strip(buf.getvalue()) assert "tracing" in out assert "tracer:preload" in out - assert "proxy:on" in out assert "sync:off" in out -def test_trace_ended_exit_before_duration() -> None: +def test_trace_ended_success() -> None: buf = io.StringIO() report = RunReportPresenter(stream=buf, caps=_tty_caps()) - report.trace_ended(duration=11.2, exit_code=0) + report.trace_ended(duration=11.2, exit_code=0, backend="preload") out = _strip(buf.getvalue()) - exit_pos = out.index("exit 0") - dur_pos = out.index("11.2s") - assert exit_pos < dur_pos # exit code appears before duration + assert "trace done" in out + assert "11.2s" in out + assert "exit 0" in out + assert "[preload]" in out + + +def test_trace_ended_nonzero_exit() -> None: + buf = io.StringIO() + caps = TerminalCaps(is_tty=True, can_color=True, can_emoji=False, width=80) + report = RunReportPresenter(stream=buf, caps=caps) + report.trace_ended(duration=1.0, exit_code=1) + raw = buf.getvalue() + assert "exit 1" in _strip(raw) + # warn_amber for non-zero exit + assert "\x1b[38;5;172m" in raw -def test_hashed_line_singular() -> None: +def test_hashed_singular() -> None: buf = io.StringIO() report = RunReportPresenter(stream=buf, caps=_tty_caps()) report.hashed(n_artifacts=1, total_bytes=1024 * 1024, duration=0.5) @@ -283,7 +212,7 @@ def test_hashed_line_singular() -> None: assert "MB/s" in out -def test_done_shows_trace_and_post() -> None: +def test_done_shows_timing_breakdown() -> None: buf = io.StringIO() report = RunReportPresenter(stream=buf, caps=_tty_caps()) report.done(exit_code=0, trace_duration=11.2, post_duration=0.6) @@ -293,12 +222,31 @@ def test_done_shows_trace_and_post() -> None: assert "post 0.6s" in out -def test_lineage_uses_trex_emoji() -> None: +# ---- quiet + pipe modes --------------------------------------------------- + + +def test_quiet_mode_emits_nothing() -> None: buf = io.StringIO() - caps = TerminalCaps(is_tty=True, can_color=False, can_emoji=True, width=80) - report = RunReportPresenter(stream=buf, caps=caps) + report = RunReportPresenter(stream=buf, caps=_tty_caps(), quiet=True) + report.trace_starting(backend="preload", proxy_active=False) + report.trace_ended(duration=0.5, exit_code=0) + report.lineage_captured() + report.summary(_make_result(), []) + report.done(exit_code=0, trace_duration=0.5, post_duration=0.1) + assert buf.getvalue() == "" + + +def test_pipe_mode_emits_only_done_line() -> None: + buf = io.StringIO() + report = RunReportPresenter(stream=buf, caps=_pipe_caps()) + report.trace_starting(backend="preload", proxy_active=False) + report.trace_ended(duration=0.5, exit_code=0) report.lineage_captured() - assert "πŸ¦–" in buf.getvalue() + report.summary(_make_result(), []) + report.done(exit_code=0, trace_duration=0.5, post_duration=0.1) + out = buf.getvalue() + assert out.count("\n") == 1 + assert out.startswith("roar: done") # ---- legacy one-shot ------------------------------------------------------- @@ -307,19 +255,8 @@ def test_lineage_uses_trex_emoji() -> None: def test_show_report_legacy() -> None: buf = io.StringIO() report = RunReportPresenter(_CapturePresenter(), stream=buf, caps=_tty_caps()) - report.show_report( - RunResult( - exit_code=0, - job_id=1, - job_uid="job12345", - duration=1.0, - inputs=[], - outputs=[], - post_duration=0.2, - ), - [], - ) + report.show_report(_make_result(post_duration=0.2), []) out = _strip(buf.getvalue()) - assert "roar show --job job12345" in out + assert "roar show --job f3fba717" in out assert "trace done" in out assert "done" in out From 4a53e05edcc0e8399ac4d7bc9e494bcb35168fa0 Mon Sep 17 00:00:00 2001 From: Chris Geyer Date: Fri, 17 Apr 2026 19:33:10 +0000 Subject: [PATCH 12/14] fix(run): resolve tracer before execution, simplify i/o line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Resolve the actual tracer backend (preload/ebpf/ptrace) before execution by calling _get_tracer_candidates() early. The trace_starting line now shows the real backend instead of "auto". - Remove [backend] suffix from trace_done line β€” redundant now that trace_starting already shows it. - Remove "← N prior jobs" from the i/o detail line. Just show input and output counts. Co-Authored-By: Claude Opus 4.6 (1M context) --- roar/execution/runtime/coordinator.py | 23 ++++++++++++----------- roar/presenters/run_report.py | 13 ++----------- tests/unit/test_run_report.py | 22 ++++------------------ 3 files changed, 18 insertions(+), 40 deletions(-) diff --git a/roar/execution/runtime/coordinator.py b/roar/execution/runtime/coordinator.py index e71f852e..7792898d 100644 --- a/roar/execution/runtime/coordinator.py +++ b/roar/execution/runtime/coordinator.py @@ -158,11 +158,18 @@ def stop_proxy_if_running() -> list: # Execute via tracer from ...core.exceptions import TracerNotFoundError - # Announce trace is starting. We don't yet know the backend (it's - # selected inside tracer.execute), so we just say "tracing"; the - # backend shows up in the "trace done" line below. + # Resolve the backend name before execution so the trace_starting + # line can show the actual tracer, not "auto". proxy_active = proxy_handle is not None - run_presenter.trace_starting(ctx.tracer_mode, proxy_active) + resolved_mode = ctx.tracer_mode or "auto" + try: + fallback = ctx.tracer_fallback if ctx.tracer_fallback is not None else True + candidates = self._tracer._get_tracer_candidates(resolved_mode, fallback) + if candidates: + resolved_mode = candidates[0][0] + except Exception: + pass + run_presenter.trace_starting(resolved_mode, proxy_active) self.logger.debug("Starting tracer execution") try: @@ -175,13 +182,7 @@ def stop_proxy_if_running() -> list: tracer_mode_override=ctx.tracer_mode, fallback_enabled_override=ctx.tracer_fallback, ) - run_presenter.trace_ended( - tracer_result.duration, - tracer_result.exit_code, - backend=getattr(tracer_result, "backend", None) - if isinstance(getattr(tracer_result, "backend", None), str) - else None, - ) + run_presenter.trace_ended(tracer_result.duration, tracer_result.exit_code) self.logger.debug( "Tracer completed: exit_code=%d, duration=%.2fs, interrupted=%s", tracer_result.exit_code, diff --git a/roar/presenters/run_report.py b/roar/presenters/run_report.py index 0c05a482..706823af 100644 --- a/roar/presenters/run_report.py +++ b/roar/presenters/run_report.py @@ -107,13 +107,11 @@ def trace_starting(self, backend: str | None, proxy_active: bool) -> None: ) self._trex(f"tracing {self._dim_sep()}{flags}") - def trace_ended(self, duration: float, exit_code: int, backend: str | None = None) -> None: + def trace_ended(self, duration: float, exit_code: int) -> None: if self._quiet or self._caps.pipe_mode: return c = self._caps.can_color parts = ["trace done"] - if backend: - parts[0] += style(f" [{backend}]", "dim", enabled=c) parts.append(self._fmt_dur(duration)) exit_s = f"exit {exit_code}" if exit_code == 0: @@ -273,14 +271,7 @@ def _render_summary(self, result: RunResult) -> None: n_out = len(result.outputs) io_parts = [] if n_in: - in_text = _plural(n_in, "input") - # Count unique prior (source) jobs. - source_jobs = { - inp.get("parent_job_uid") for inp in result.inputs if inp.get("parent_job_uid") - } - if source_jobs: - in_text += f" ← {_plural(len(source_jobs), 'prior job')}" - io_parts.append(in_text) + io_parts.append(_plural(n_in, "input")) if n_out: io_parts.append(_plural(n_out, "output")) if io_parts: diff --git a/tests/unit/test_run_report.py b/tests/unit/test_run_report.py index 82460a58..254ac644 100644 --- a/tests/unit/test_run_report.py +++ b/tests/unit/test_run_report.py @@ -67,14 +67,14 @@ def _make_result(**overrides: Any) -> RunResult: # ---- summary detail lines ------------------------------------------------- -def test_io_line_counts_inputs_outputs_and_prior_jobs() -> None: +def test_io_line_counts_inputs_and_outputs() -> None: buf = io.StringIO() report = RunReportPresenter(stream=buf, caps=_tty_caps()) report.summary( _make_result( inputs=[ - {"path": "/a/in1.txt", "hashes": [], "parent_job_uid": "aaa11111"}, - {"path": "/a/in2.txt", "hashes": [], "parent_job_uid": "bbb22222"}, + {"path": "/a/in1.txt", "hashes": []}, + {"path": "/a/in2.txt", "hashes": []}, ], outputs=[{"path": "/a/out.txt", "hashes": []}], ), @@ -83,22 +83,9 @@ def test_io_line_counts_inputs_outputs_and_prior_jobs() -> None: out = _strip(buf.getvalue()) assert "i/o" in out assert "2 inputs" in out - assert "2 prior jobs" in out assert "1 output" in out -def test_io_line_omits_prior_jobs_when_none() -> None: - buf = io.StringIO() - report = RunReportPresenter(stream=buf, caps=_tty_caps()) - report.summary( - _make_result(inputs=[{"path": "/a/in.txt", "hashes": []}], outputs=[]), - [], - ) - out = _strip(buf.getvalue()) - assert "1 input" in out - assert "prior" not in out - - def test_job_line_shows_bold_hash() -> None: buf = io.StringIO() caps = TerminalCaps(is_tty=True, can_color=True, can_emoji=False, width=80) @@ -183,12 +170,11 @@ def test_trace_starting() -> None: def test_trace_ended_success() -> None: buf = io.StringIO() report = RunReportPresenter(stream=buf, caps=_tty_caps()) - report.trace_ended(duration=11.2, exit_code=0, backend="preload") + report.trace_ended(duration=11.2, exit_code=0) out = _strip(buf.getvalue()) assert "trace done" in out assert "11.2s" in out assert "exit 0" in out - assert "[preload]" in out def test_trace_ended_nonzero_exit() -> None: From 5fbfb29b441da63ebedbaf476aa225cb225a08a4 Mon Sep 17 00:00:00 2001 From: Chris Geyer Date: Fri, 17 Apr 2026 19:35:47 +0000 Subject: [PATCH 13/14] fix(run): read config tracer.default for trace_starting line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The early backend resolution was hardcoding "auto" when no CLI --tracer flag was given, ignoring the config's tracer.default setting. Now mirrors execute()'s resolution: config default β†’ CLI override β†’ auto fallback. `roar tracer ptrace` followed by `roar run` now correctly shows tracer:ptrace. Co-Authored-By: Claude Opus 4.6 (1M context) --- roar/execution/runtime/coordinator.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/roar/execution/runtime/coordinator.py b/roar/execution/runtime/coordinator.py index 7792898d..b3e8d9ed 100644 --- a/roar/execution/runtime/coordinator.py +++ b/roar/execution/runtime/coordinator.py @@ -159,17 +159,23 @@ def stop_proxy_if_running() -> list: from ...core.exceptions import TracerNotFoundError # Resolve the backend name before execution so the trace_starting - # line can show the actual tracer, not "auto". + # line can show the actual tracer, not "auto". Mirror the same + # resolution logic that execute() uses (config β†’ override β†’ auto). proxy_active = proxy_handle is not None - resolved_mode = ctx.tracer_mode or "auto" + resolved_mode: str | None = None try: - fallback = ctx.tracer_fallback if ctx.tracer_fallback is not None else True - candidates = self._tracer._get_tracer_candidates(resolved_mode, fallback) + mode = ctx.tracer_mode or self._tracer._get_tracer_mode() + fallback = ( + ctx.tracer_fallback + if ctx.tracer_fallback is not None + else self._tracer._get_fallback_enabled() + ) + candidates = self._tracer._get_tracer_candidates(mode, fallback) if candidates: resolved_mode = candidates[0][0] except Exception: pass - run_presenter.trace_starting(resolved_mode, proxy_active) + run_presenter.trace_starting(resolved_mode or ctx.tracer_mode, proxy_active) self.logger.debug("Starting tracer execution") try: From af51979e49d65e2a09a18d5931ef9e5a48b3e69e Mon Sep 17 00:00:00 2001 From: Chris Geyer Date: Fri, 17 Apr 2026 19:42:02 +0000 Subject: [PATCH 14/14] chore(run): clean up dead code from iteration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove unused symbols left over from the v5β†’v8 iteration: - _HASH_W, _pad, _visible_len, format_size (run_report.py) - Unused `re` and `os` imports - Duplicate total_hash_bytes / hash_duration computation in coordinator (computed once now, used for both the hashed line and RunResult) No behavior change. Co-Authored-By: Claude Opus 4.6 (1M context) --- roar/execution/runtime/coordinator.py | 12 ++++-------- roar/presenters/run_report.py | 24 ------------------------ 2 files changed, 4 insertions(+), 32 deletions(-) diff --git a/roar/execution/runtime/coordinator.py b/roar/execution/runtime/coordinator.py index b3e8d9ed..86ebd837 100644 --- a/roar/execution/runtime/coordinator.py +++ b/roar/execution/runtime/coordinator.py @@ -303,12 +303,12 @@ def stop_proxy_if_running() -> list: self._cleanup_logs(tracer_result.tracer_log_path, tracer_result.inject_log_path) # "hashed" throughput line. - total_hash_bytes_early = sum(f.get("size") or 0 for f in written_file_info) - hash_dur = t_record_end - t_record_start + total_hash_bytes = sum(f.get("size") or 0 for f in written_file_info) + hash_duration = t_record_end - t_record_start run_presenter.hashed( n_artifacts=len(read_file_info) + len(written_file_info), - total_bytes=total_hash_bytes_early, - duration=hash_dur, + total_bytes=total_hash_bytes, + duration=hash_duration, ) run_presenter.lineage_captured() @@ -345,10 +345,6 @@ def stop_proxy_if_running() -> list: if not isinstance(backend_name, str): backend_name = None - # Hash throughput: sum of all output sizes + record duration. - total_hash_bytes = sum(f.get("size") or 0 for f in written_file_info) - hash_duration = t_record_end - t_record_start - # Git info (best-effort, never fail the run for this). git_branch, git_short_commit, git_clean = None, None, True try: diff --git a/roar/presenters/run_report.py b/roar/presenters/run_report.py index 706823af..801caab8 100644 --- a/roar/presenters/run_report.py +++ b/roar/presenters/run_report.py @@ -13,7 +13,6 @@ from __future__ import annotations -import re import sys from contextlib import contextmanager from typing import IO @@ -27,7 +26,6 @@ # Constants # --------------------------------------------------------------------------- -_HASH_W = 8 _SMALL_RUN = 5 # skip transient hashing progress below this count @@ -39,28 +37,6 @@ def _plural(n: int, singular: str, plural: str | None = None) -> str: # Helpers # --------------------------------------------------------------------------- -_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") - - -def _visible_len(s: str) -> int: - return len(_ANSI_RE.sub("", s)) - - -def _pad(text: str, width: int) -> str: - vis = _visible_len(text) - return text + " " * max(0, width - vis) - - -def format_size(size_bytes: int | None) -> str: - if size_bytes is None: - return "?" - size: float = float(size_bytes) - for unit in ["B", "KB", "MB", "GB"]: - if abs(size) < 1024: - return f"{size:.1f}{unit}" if unit != "B" else f"{int(size)}{unit}" - size /= 1024 - return f"{size:.1f}TB" - class _NullProgress: def advance(self, delta: int = 1) -> None: