From 44c916bf08d35694ca104f9db08fdedab0293e24 Mon Sep 17 00:00:00 2001 From: Will Winder Date: Thu, 18 Jun 2026 15:05:37 -0400 Subject: [PATCH 1/9] build/devenv: add interactive progress reporter for ccv up and test Introduces a Reporter interface in the runtime package plus bubbletea, simple, and noop implementations so ccv up/test/restart render a live spinner TUI on a terminal and plain one-line output in CI or verbose mode. Redirects build/env noise to a log file and prints an environment summary table when the run completes. --- build/devenv/cli/ccv.go | 259 ++++++++++++-------- build/devenv/environment_phased.go | 17 +- build/devenv/go.mod | 17 ++ build/devenv/go.sum | 29 +++ build/devenv/reporter/bubbletea.go | 291 +++++++++++++++++++++++ build/devenv/reporter/factory.go | 28 +++ build/devenv/reporter/simple.go | 77 ++++++ build/devenv/reporter/summary.go | 192 +++++++++++++++ build/devenv/runtime/component.go | 7 + build/devenv/runtime/environment.go | 36 +-- build/devenv/runtime/environment_test.go | 2 +- build/devenv/runtime/reporter.go | 34 +++ 12 files changed, 867 insertions(+), 122 deletions(-) create mode 100644 build/devenv/reporter/bubbletea.go create mode 100644 build/devenv/reporter/factory.go create mode 100644 build/devenv/reporter/simple.go create mode 100644 build/devenv/reporter/summary.go create mode 100644 build/devenv/runtime/reporter.go diff --git a/build/devenv/cli/ccv.go b/build/devenv/cli/ccv.go index a0772fc69..31ac6578d 100644 --- a/build/devenv/cli/ccv.go +++ b/build/devenv/cli/ccv.go @@ -36,6 +36,9 @@ import ( "github.com/smartcontractkit/chainlink-ccv/build/devenv/cli/log" "github.com/smartcontractkit/chainlink-ccv/build/devenv/cli/send" "github.com/smartcontractkit/chainlink-ccv/build/devenv/evm" + "github.com/smartcontractkit/chainlink-ccv/build/devenv/reporter" + devenvruntime "github.com/smartcontractkit/chainlink-ccv/build/devenv/runtime" + "github.com/smartcontractkit/chainlink-ccv/build/devenv/util" ) const ( @@ -43,8 +46,10 @@ const ( LocalCCVDashboard = "http://localhost:3000/d/f8a04cef-653f-46d3-86df-87c532300672/ccv-services?orgId=1&refresh=5s" ) -// newEnvFn is set by PersistentPreRunE based on the --env-mode flag. -var newEnvFn func() error +// newEnvFn is set by PersistentPreRunE (or applyProfile) based on the +// --env-mode flag. The Reporter receives component-level events in phased +// mode; legacy mode wraps the monolith with stage-level events only. +var newEnvFn func(r devenvruntime.Reporter) error var rootCmd = &cobra.Command{ Use: "ccv", @@ -67,15 +72,15 @@ var rootCmd = &cobra.Command{ } switch mode { case "legacy": - // Both env constructors return a value that the up/restart commands - // discard, so adapt them to the error-only fn. - newEnvFn = func() error { + newEnvFn = func(r devenvruntime.Reporter) error { + r.OnStageStart("env") _, err := ccv.NewEnvironment() + r.OnStageFinish("env", err) return err } case "phased": - newEnvFn = func() error { - _, err := ccv.NewPhasedEnvironment() + newEnvFn = func(r devenvruntime.Reporter) error { + _, err := ccv.NewPhasedEnvironmentWithReporter(r) return err } default: @@ -98,7 +103,17 @@ var restartCmd = &cobra.Command{ if err := framework.RemoveTestContainers(); err != nil { return fmt.Errorf("failed to clean Docker resources: %w", err) } - return newEnvFn() + verbose, _ := cmd.Flags().GetBool("verbose") + term, cleanup, err := redirectToLogFile(verbose, "ccv-restart") + if err != nil { + return err + } + defer cleanup() + r := reporter.New(verbose, term) + outToml := resolveOutToml() + runErr := r.Run(func() error { return newEnvFn(r) }) + r.PrintSummary(outToml) + return runErr }, } @@ -111,7 +126,17 @@ var upCmd = &cobra.Command{ if err := applyEnvConfig(cmd, args); err != nil { return err } - return newEnvFn() + verbose, _ := cmd.Flags().GetBool("verbose") + term, cleanup, err := redirectToLogFile(verbose, "ccv-up") + if err != nil { + return err + } + defer cleanup() + r := reporter.New(verbose, term) + outToml := resolveOutToml() + runErr := r.Run(func() error { return newEnvFn(r) }) + r.PrintSummary(outToml) + return runErr }, } @@ -240,13 +265,15 @@ func applyProfile(profilePath string) error { switch p.Environment { case "legacy": - newEnvFn = func() error { + newEnvFn = func(r devenvruntime.Reporter) error { + r.OnStageStart("env") _, err := ccv.NewEnvironment() + r.OnStageFinish("env", err) return err } case "phased": - newEnvFn = func() error { - _, err := ccv.NewPhasedEnvironment() + newEnvFn = func(r devenvruntime.Reporter) error { + _, err := ccv.NewPhasedEnvironmentWithReporter(r) return err } } @@ -536,7 +563,7 @@ Examples: profileName, _ := cmd.Flags().GetString("profile") timeout, _ := cmd.Flags().GetDuration("timeout") buildTarget, _ := cmd.Flags().GetString("build") - logPath, _ := cmd.Flags().GetString("log") + verbose, _ := cmd.Flags().GetBool("verbose") if len(args) > 0 && patternFlag != "" { return fmt.Errorf("cannot combine a suite name with --pattern") @@ -563,107 +590,90 @@ Examples: // file so the terminal only shows concise progress lines. We redirect at // the OS fd level (dup2) so that subprocesses, zerolog, and fmt.Print* // calls all land in the log regardless of how they open stdout/stderr. - progress := func(msg string) { fmt.Fprintln(os.Stderr, msg) } - if logPath != "" { - lf, err := os.Create(logPath) - if err != nil { - return fmt.Errorf("failed to create log file %s: %w", logPath, err) - } - defer lf.Close() - - // Save the real terminal fds so progress messages can still reach it. - realStdoutFd, _ := syscall.Dup(int(os.Stdout.Fd())) - realStderrFd, _ := syscall.Dup(int(os.Stderr.Fd())) - realTerm := os.NewFile(uintptr(realStderrFd), "real_stderr") - defer func() { - // Restore terminal fds on exit. - _ = syscall.Dup2(realStdoutFd, int(os.Stdout.Fd())) - _ = syscall.Dup2(realStderrFd, int(os.Stderr.Fd())) - _ = syscall.Close(realStdoutFd) - // realStderrFd is owned by realTerm; closing realTerm closes it. - _ = realTerm.Close() - }() - - // Redirect stdout and stderr to the log file. - _ = syscall.Dup2(int(lf.Fd()), int(os.Stdout.Fd())) - _ = syscall.Dup2(int(lf.Fd()), int(os.Stderr.Fd())) - - progress = func(msg string) { - fmt.Fprintf(realTerm, "[ccv test] %s\n", msg) - } + // In verbose mode no redirect happens; the caller gets raw zerolog output. + term, cleanup, err := redirectToLogFile(verbose, "ccv-test") + if err != nil { + return err } - - // Stage 1: optional image build. - if buildEnabled { - progress(fmt.Sprintf("building images (just %s)...", buildTarget)) - buildCmd := exec.Command("just", buildTarget) - buildCmd.Stdout = os.Stdout - buildCmd.Stderr = os.Stderr - if err := buildCmd.Run(); err != nil { - return fmt.Errorf("just %s failed: %w", buildTarget, err) + defer cleanup() + + r := reporter.New(verbose, term) + + var testErr error + runErr := r.Run(func() error { + // Stage 1: optional image build. + if buildEnabled { + r.OnStageStart("build") + buildCmd := exec.Command("just", buildTarget) + buildCmd.Stdout = os.Stdout + buildCmd.Stderr = os.Stderr + buildErr := buildCmd.Run() + r.OnStageFinish("build", buildErr) + if buildErr != nil { + return fmt.Errorf("just %s failed: %w", buildTarget, buildErr) + } } - } - // Stage 2: optional environment start. - var extraEnv []string - if profileName != "" { - if !strings.HasSuffix(profileName, ".profile") { - profileName += ".profile" - } - outputFile := fmt.Sprintf("test-%s-out.toml", generateRunID()) - absOutput, err := filepath.Abs(outputFile) - if err != nil { - return fmt.Errorf("failed to resolve output path: %w", err) + // Stage 2: optional environment start. + var extraEnv []string + if profileName != "" { + if !strings.HasSuffix(profileName, ".profile") { + profileName += ".profile" + } + outputFile := fmt.Sprintf("test-%s-out.toml", generateRunID()) + absOutput, err := filepath.Abs(outputFile) + if err != nil { + return fmt.Errorf("failed to resolve output path: %w", err) + } + _ = framework.RemoveTestContainers() + + if err := applyProfile(profileName); err != nil { + return err + } + _ = os.Setenv("CTF_OUTPUT", outputFile) + _ = os.Setenv("TESTCONTAINERS_RYUK_DISABLED", "true") + if err := newEnvFn(r); err != nil { + return fmt.Errorf("environment startup failed: %w", err) + } + extraEnv = []string{fmt.Sprintf("SMOKE_TEST_CONFIG=%s", absOutput)} } - progress("tearing down any existing environment...") - _ = framework.RemoveTestContainers() - progress(fmt.Sprintf("starting environment (profile: %s, output: %s)...", profileName, absOutput)) - if err := applyProfile(profileName); err != nil { - return err + // Stage 3: run the test. + timeoutStr := "0" + if timeout > 0 { + timeoutStr = timeout.String() } - _ = os.Setenv("CTF_OUTPUT", outputFile) - _ = os.Setenv("TESTCONTAINERS_RYUK_DISABLED", "true") - if err := newEnvFn(); err != nil { - return fmt.Errorf("environment startup failed: %w", err) + goTestArgs := []string{ + "test", "-v", "-count=1", + "-run", testPattern, + fmt.Sprintf("-timeout=%s", timeoutStr), } - extraEnv = []string{fmt.Sprintf("SMOKE_TEST_CONFIG=%s", absOutput)} - } + r.OnStageStart("test") + goTestCmd := exec.Command("go", goTestArgs...) + goTestCmd.Dir = testDir + goTestCmd.Stdout = os.Stdout + goTestCmd.Stderr = os.Stderr + goTestCmd.Stdin = os.Stdin + if len(extraEnv) > 0 { + goTestCmd.Env = append(os.Environ(), extraEnv...) + } + testErr = goTestCmd.Run() + r.OnStageFinish("test", testErr) + return testErr + }) - // Stage 3: run the test. - timeoutStr := "0" - if timeout > 0 { - timeoutStr = timeout.String() - } - goTestArgs := []string{ - "test", "-v", "-count=1", - "-run", testPattern, - fmt.Sprintf("-timeout=%s", timeoutStr), - } - progress(fmt.Sprintf("running test %s...", testPattern)) - goTestCmd := exec.Command("go", goTestArgs...) - goTestCmd.Dir = testDir - goTestCmd.Stdout = os.Stdout - goTestCmd.Stderr = os.Stderr - goTestCmd.Stdin = os.Stdin - if len(extraEnv) > 0 { - goTestCmd.Env = append(os.Environ(), extraEnv...) + if runErr == nil { + runErr = testErr } - if err := goTestCmd.Run(); err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - if logPath != "" { - progress(fmt.Sprintf("FAILED (log: %s)", logPath)) - } + if runErr != nil { + if exitErr, ok := runErr.(*exec.ExitError); ok { if status, ok := exitErr.Sys().(syscall.WaitStatus); ok { os.Exit(status.ExitStatus()) } os.Exit(1) } - return fmt.Errorf("test run failed: %w", err) - } - if logPath != "" { - progress(fmt.Sprintf("PASSED (log: %s)", logPath)) + return fmt.Errorf("test run failed: %w", runErr) } return nil }, @@ -706,6 +716,55 @@ func generateRunID() string { return id.String()[:8] } +// redirectToLogFile redirects os.Stdout and os.Stderr to an auto-created log +// file in CCVConfigDir() unless verbose is true. It returns the writer that +// the fancy reporter should render to (the saved real-terminal fd), and a +// cleanup func that restores the original fds. In verbose mode nothing is +// redirected and the returned writer is os.Stderr. +func redirectToLogFile(verbose bool, prefix string) (term *os.File, cleanup func(), err error) { + noop := func() {} + if verbose { + return os.Stderr, noop, nil + } + + logPath := filepath.Join(util.CCVConfigDir(), fmt.Sprintf("%s-%d.log", prefix, time.Now().UnixMilli())) + lf, err := os.Create(logPath) + if err != nil { + return nil, noop, fmt.Errorf("failed to create log file %s: %w", logPath, err) + } + + realStdoutFd, _ := syscall.Dup(int(os.Stdout.Fd())) + realStderrFd, _ := syscall.Dup(int(os.Stderr.Fd())) + realTerm := os.NewFile(uintptr(realStderrFd), "real_stderr") + + fmt.Fprintf(realTerm, "log: %s\n", logPath) + + _ = syscall.Dup2(int(lf.Fd()), int(os.Stdout.Fd())) + _ = syscall.Dup2(int(lf.Fd()), int(os.Stderr.Fd())) + + cleanupFn := func() { + _ = syscall.Dup2(realStdoutFd, int(os.Stdout.Fd())) + _ = syscall.Dup2(realStderrFd, int(os.Stderr.Fd())) + _ = syscall.Close(realStdoutFd) + _ = realTerm.Close() + _ = lf.Close() + } + return realTerm, cleanupFn, nil +} + +// resolveOutToml returns the path to the env-out.toml that Store() will have +// written. It mirrors the logic in config.go:Store without re-running it. +func resolveOutToml() string { + if override := os.Getenv(ccv.EnvVarTestOutput); override != "" { + return override + } + base, err := ccv.BaseConfigPath() + if err != nil { + return "" + } + return fmt.Sprintf("%s-out.toml", strings.ReplaceAll(base, ".toml", "")) +} + var indexerDBShellCmd = &cobra.Command{ Use: "db-shell", Aliases: []string{"db"}, @@ -934,6 +993,7 @@ func init() { rootCmd.PersistentFlags().BoolP("debug", "d", false, "Enable running services with dlv to allow remote debugging.") rootCmd.PersistentFlags().String("env-mode", "legacy", "Environment startup mode: legacy (default) or phased.") rootCmd.PersistentFlags().String("log-level", "", "Log level for services that support it (e.g. debug, info, warn)") + rootCmd.PersistentFlags().Bool("verbose", false, "Show raw log output instead of the fancy progress UI") // Fund addresses rootCmd.AddCommand(fundAddressesCmd) @@ -976,7 +1036,6 @@ func init() { testCmd.Flags().StringP("pattern", "r", "", "Raw Go test pattern (alternative to a named suite positional arg)") testCmd.Flags().Duration("timeout", 0, "Test timeout (0 = unlimited)") testCmd.Flags().String("build", "build-docker", "Just target to build Docker images before starting (e.g. build-docker, build-docker-ci); pass 'false' to skip; silently ignored when --profile is absent") - testCmd.Flags().String("log", "", "Write verbose output (build, env, test) to this file; only progress lines appear on the terminal") rootCmd.AddCommand(testCmd) rootCmd.AddCommand(indexerDBShellCmd) rootCmd.AddCommand(printAddressesCmd) diff --git a/build/devenv/environment_phased.go b/build/devenv/environment_phased.go index 7c3a23d11..a1402a191 100644 --- a/build/devenv/environment_phased.go +++ b/build/devenv/environment_phased.go @@ -10,12 +10,15 @@ import ( "github.com/smartcontractkit/chainlink-ccv/build/devenv/timing" ) -// NewPhasedEnvironment creates a new CCIP CCV environment using the phased -// component runtime. It loads the raw TOML config, hands control to the -// runtime, then serializes the raw accumulated output map (minus runtime-only -// "_"-prefixed keys) to the env-out.toml file consumed by downstream tests. It -// returns the full accumulated output map. -func NewPhasedEnvironment() (out map[string]any, err error) { +// NewPhasedEnvironment creates the environment using the phased runtime with a +// no-op reporter. Use NewPhasedEnvironmentWithReporter to supply a live reporter. +func NewPhasedEnvironment() (map[string]any, error) { + return NewPhasedEnvironmentWithReporter(devenvruntime.NoopReporter{}) +} + +// NewPhasedEnvironmentWithReporter creates a new CCIP CCV environment using the +// phased component runtime, reporting lifecycle events to the provided reporter. +func NewPhasedEnvironmentWithReporter(reporter devenvruntime.Reporter) (out map[string]any, err error) { ctx := L.WithContext(context.Background()) configs := strings.Split(os.Getenv(EnvVarTestConfigs), ",") @@ -49,7 +52,7 @@ func NewPhasedEnvironment() (out map[string]any, err error) { sendStartupMetrics(dxTracker, err, elapsed) }() - out, err = devenvruntime.NewEnvironmentWithRegistry(ctx, rawConfig, devenvruntime.GlobalRegistry(), newDevenvEffectExecutor(), L) + out, err = devenvruntime.NewEnvironmentWithRegistry(ctx, rawConfig, devenvruntime.GlobalRegistry(), newDevenvEffectExecutor(), L, reporter) if err != nil { return nil, err } diff --git a/build/devenv/go.mod b/build/devenv/go.mod index 82081931c..731edad37 100644 --- a/build/devenv/go.mod +++ b/build/devenv/go.mod @@ -12,6 +12,9 @@ require ( github.com/BurntSushi/toml v1.6.0 github.com/Masterminds/semver/v3 v3.5.0 github.com/c-bata/go-prompt v0.2.6 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/x/term v0.2.1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/ethereum/go-ethereum v1.17.4 github.com/go-resty/resty/v2 v2.17.2 @@ -69,6 +72,20 @@ require ( go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect ) +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect +) + require ( cloud.google.com/go/auth v0.16.2 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect diff --git a/build/devenv/go.sum b/build/devenv/go.sum index 69af32b5d..ad680c887 100644 --- a/build/devenv/go.sum +++ b/build/devenv/go.sum @@ -131,6 +131,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUY github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc= github.com/aws/smithy-go v1.27.2 h1:y9NPmSE6am6LjEFPfqHqG/jJk7AauQvhCJONKh7kpzk= github.com/aws/smithy-go v1.27.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df h1:GSoSVRLoBaFpOOds6QyY1L8AX7uoY+Ln3BHc22W40X0= @@ -210,6 +212,18 @@ github.com/cespare/cp v1.1.1/go.mod h1:SOGHArjBr4JWaSDEVpWpo/hNg6RoKrls6Oh40hiwW github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -332,6 +346,8 @@ github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/ethereum/c-kzg-4844/v2 v2.1.6 h1:xQymkKCT5E2Jiaoqf3v4wsNgjZLY0lRSkZn27fRjSls= github.com/ethereum/c-kzg-4844/v2 v2.1.6/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw= github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJhy07IMfEKuARQ9TKojGqLVNxQajaXEp/BoqSk= @@ -799,6 +815,8 @@ github.com/linode/linodego v1.49.0 h1:MNd3qwvQzbXB5mCpvdCqlUIu1RPA9oC+50LyB9kK+G github.com/linode/linodego v1.49.0/go.mod h1:B+HAM3//4w1wOS0BwdaQBKwBxlfe6kYJ7bSC6jJ/xtc= github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6+8hHTyWBeQ/P4Nb7dd4/0ohEcWQuM= github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= @@ -827,6 +845,8 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.6/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= @@ -895,6 +915,12 @@ github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 h1:mPMvm github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1/go.mod h1:ye2e/VUEtE2BHE+G/QcKkcLQVAEJoYRFj5VUOQatCRE= github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= @@ -1310,6 +1336,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xdrpp/goxdr v0.1.1 h1:E1B2c6E8eYhOVyd7yEpOyopzTPirUeF6mVOfXfGyJyc= github.com/xdrpp/goxdr v0.1.1/go.mod h1:dXo1scL/l6s7iME1gxHWo2XCppbHEKZS7m/KyYWkNzA= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/xssnick/tonutils-go v1.14.1 h1:zV/iVYl/h3hArS+tPsd9XrSFfGert3r21caMltPSeHg= @@ -1613,6 +1641,7 @@ golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/build/devenv/reporter/bubbletea.go b/build/devenv/reporter/bubbletea.go new file mode 100644 index 000000000..9d33fac5f --- /dev/null +++ b/build/devenv/reporter/bubbletea.go @@ -0,0 +1,291 @@ +package reporter + +import ( + "fmt" + "io" + "strings" + "sync" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + devenvruntime "github.com/smartcontractkit/chainlink-ccv/build/devenv/runtime" +) + +// ── messages ──────────────────────────────────────────────────────────────── + +type tickMsg time.Time + +type componentStartedMsg struct { + phase int + name string +} + +type componentFinishedMsg struct { + phase int + name string + err error +} + +type statusUpdateMsg struct { + phase int + name string + status string +} + +type stageStartedMsg struct{ name string } +type stageFinishedMsg struct { + name string + err error +} +type doneMsg struct{ err error } + +// ── styles ─────────────────────────────────────────────────────────────────── + +var ( + styleOK = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // green + styleFail = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // red + styleStage = lipgloss.NewStyle().Foreground(lipgloss.Color("4")) // blue + styleSep = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // dark gray + styleFooter = lipgloss.NewStyle().Foreground(lipgloss.Color("11")) // yellow + styleDim = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // dark gray +) + +var spinnerFrames = []string{"|", "/", "-", "\\"} + +// ── model ──────────────────────────────────────────────────────────────────── + +type activeComp struct { + phase int + name string + start time.Time + status string +} + +type tuiModel struct { + log []string // completed / stage lines + active map[string]*activeComp + activeOrder []string // insertion-ordered keys for stable display + frame int + done bool + finalErr error + width int +} + +func newModel() tuiModel { + return tuiModel{ + active: make(map[string]*activeComp), + width: 80, + } +} + +func compKey(phase int, name string) string { + return fmt.Sprintf("%d:%s", phase, name) +} + +func (m tuiModel) Init() tea.Cmd { + return tickEvery(100 * time.Millisecond) +} + +func tickEvery(d time.Duration) tea.Cmd { + return tea.Tick(d, func(t time.Time) tea.Msg { return tickMsg(t) }) +} + +func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + + case tea.WindowSizeMsg: + m.width = msg.Width + + case tickMsg: + m.frame++ + if m.done && len(m.active) == 0 { + return m, tea.Quit + } + return m, tickEvery(100 * time.Millisecond) + + case stageStartedMsg: + m.log = append(m.log, styleStage.Render("── "+msg.name)) + + case stageFinishedMsg: + if msg.err != nil { + m.log = append(m.log, styleFail.Render(fmt.Sprintf("✗ %s failed: %v", msg.name, msg.err))) + } + + case componentStartedMsg: + key := compKey(msg.phase, msg.name) + m.active[key] = &activeComp{phase: msg.phase, name: msg.name, start: time.Now()} + m.activeOrder = append(m.activeOrder, key) + + case componentFinishedMsg: + key := compKey(msg.phase, msg.name) + cs, ok := m.active[key] + start := time.Now() + if ok { + start = cs.start + } + dur := time.Since(start).Round(time.Millisecond) + delete(m.active, key) + // Remove from ordered list. + for i, k := range m.activeOrder { + if k == key { + m.activeOrder = append(m.activeOrder[:i], m.activeOrder[i+1:]...) + break + } + } + if msg.err != nil { + m.log = append(m.log, styleFail.Render( + fmt.Sprintf("✗ [%d] %-28s %6s error: %v", msg.phase, msg.name, dur, msg.err))) + } else { + m.log = append(m.log, styleOK.Render( + fmt.Sprintf("✓ [%d] %-28s %6s", msg.phase, msg.name, dur))) + } + if m.done && len(m.active) == 0 { + return m, tea.Quit + } + + case statusUpdateMsg: + key := compKey(msg.phase, msg.name) + if cs, ok := m.active[key]; ok { + cs.status = msg.status + } + + case doneMsg: + m.done = true + m.finalErr = msg.err + if len(m.active) == 0 { + return m, tea.Quit + } + } + + return m, nil +} + +func (m tuiModel) View() string { + var sb strings.Builder + + for _, line := range m.log { + sb.WriteString(line) + sb.WriteString("\n") + } + + if len(m.active) == 0 { + return sb.String() + } + + sep := strings.Repeat("─", min(m.width, 60)) + sb.WriteString(styleSep.Render(sep)) + sb.WriteString("\n") + + spin := spinnerFrames[m.frame%len(spinnerFrames)] + for _, key := range m.activeOrder { + cs, ok := m.active[key] + if !ok { + continue + } + elapsed := time.Since(cs.start).Round(time.Second) + line := fmt.Sprintf("%s %-28s %4s", spin, cs.name, elapsed) + if cs.status != "" { + line += styleFooter.Render(" | "+cs.status) + } + sb.WriteString(styleDim.Render(line)) + sb.WriteString("\n") + } + + return sb.String() +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +// ── reporter ───────────────────────────────────────────────────────────────── + +type bubbletearReporter struct { + mu sync.Mutex + program *tea.Program + finalErr error + out io.Writer + stopPollers map[string]func() +} + +func newBubbletearReporter(out io.Writer) *bubbletearReporter { + return &bubbletearReporter{ + out: out, + stopPollers: make(map[string]func()), + } +} + +func (r *bubbletearReporter) OnStart(phase int, name string, comp devenvruntime.Component) { + r.program.Send(componentStartedMsg{phase: phase, name: name}) + + s, ok := comp.(devenvruntime.Statuser) + if !ok { + return + } + + stop := make(chan struct{}) + key := compKey(phase, name) + r.mu.Lock() + r.stopPollers[key] = sync.OnceFunc(func() { close(stop) }) + r.mu.Unlock() + + go func() { + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-stop: + return + case <-ticker.C: + r.program.Send(statusUpdateMsg{phase: phase, name: name, status: s.Status()}) + } + } + }() +} + +func (r *bubbletearReporter) OnFinish(phase int, name string, err error) { + key := compKey(phase, name) + r.mu.Lock() + if stop, ok := r.stopPollers[key]; ok { + stop() + delete(r.stopPollers, key) + } + r.mu.Unlock() + r.program.Send(componentFinishedMsg{phase: phase, name: name, err: err}) +} + +func (r *bubbletearReporter) OnStageStart(name string) { + r.program.Send(stageStartedMsg{name: name}) +} + +func (r *bubbletearReporter) OnStageFinish(name string, err error) { + r.program.Send(stageFinishedMsg{name: name, err: err}) +} + +func (r *bubbletearReporter) Run(fn func() error) error { + prog := tea.NewProgram(newModel(), tea.WithOutput(r.out)) + r.program = prog + + go func() { + err := fn() + prog.Send(doneMsg{err: err}) + }() + + m, err := prog.Run() + if err != nil { + return err + } + if tm, ok := m.(tuiModel); ok { + r.finalErr = tm.finalErr + } + return r.finalErr +} + +func (r *bubbletearReporter) PrintSummary(outTomlPath string) { + printSummary(r.out, outTomlPath) +} diff --git a/build/devenv/reporter/factory.go b/build/devenv/reporter/factory.go new file mode 100644 index 000000000..476dccd07 --- /dev/null +++ b/build/devenv/reporter/factory.go @@ -0,0 +1,28 @@ +package reporter + +import ( + "io" + "os" + + "github.com/charmbracelet/x/term" + + devenvruntime "github.com/smartcontractkit/chainlink-ccv/build/devenv/runtime" +) + +// New returns the appropriate Reporter for the current output context. +// +// - verbose=true → NoopReporter (caller keeps raw zerolog output) +// - TTY detected → BubbletearReporter (animated TUI) +// - otherwise → SimpleFancyReporter (line-based progress) +// +// out is the writer that the reporter should render to (typically the real +// terminal fd after stdout/stderr have been redirected to a log file). +func New(verbose bool, out io.Writer) devenvruntime.Reporter { + if verbose { + return devenvruntime.NoopReporter{} + } + if f, ok := out.(*os.File); ok && term.IsTerminal(f.Fd()) { + return newBubbletearReporter(out) + } + return newSimpleReporter(out) +} diff --git a/build/devenv/reporter/simple.go b/build/devenv/reporter/simple.go new file mode 100644 index 000000000..2c46c4b59 --- /dev/null +++ b/build/devenv/reporter/simple.go @@ -0,0 +1,77 @@ +package reporter + +import ( + "fmt" + "io" + "sync" + "time" + + devenvruntime "github.com/smartcontractkit/chainlink-ccv/build/devenv/runtime" +) + +type componentStart struct { + phase int + name string + start time.Time +} + +// simpleReporter writes one line per component completion to out. +// No cursor movement — safe for pipes, CI, and log capture. +type simpleReporter struct { + mu sync.Mutex + out io.Writer + starts map[string]componentStart // key: "phase:name" +} + +func newSimpleReporter(out io.Writer) *simpleReporter { + return &simpleReporter{ + out: out, + starts: make(map[string]componentStart), + } +} + +func (r *simpleReporter) key(phase int, name string) string { + return fmt.Sprintf("%d:%s", phase, name) +} + +func (r *simpleReporter) OnStart(phase int, name string, _ devenvruntime.Component) { + r.mu.Lock() + defer r.mu.Unlock() + r.starts[r.key(phase, name)] = componentStart{phase: phase, name: name, start: time.Now()} +} + +func (r *simpleReporter) OnFinish(phase int, name string, err error) { + r.mu.Lock() + defer r.mu.Unlock() + k := r.key(phase, name) + cs, ok := r.starts[k] + if !ok { + cs = componentStart{phase: phase, name: name, start: time.Now()} + } + delete(r.starts, k) + + dur := time.Since(cs.start).Round(time.Millisecond) + if err != nil { + fmt.Fprintf(r.out, "✗ [%d] %-28s %6s error: %v\n", phase, name, dur, err) + } else { + fmt.Fprintf(r.out, "✓ [%d] %-28s %6s\n", phase, name, dur) + } +} + +func (r *simpleReporter) OnStageStart(name string) { + fmt.Fprintf(r.out, "── %s\n", name) +} + +func (r *simpleReporter) OnStageFinish(name string, err error) { + if err != nil { + fmt.Fprintf(r.out, "✗ %s failed: %v\n", name, err) + } +} + +func (r *simpleReporter) Run(fn func() error) error { + return fn() +} + +func (r *simpleReporter) PrintSummary(outTomlPath string) { + printSummary(r.out, outTomlPath) +} diff --git a/build/devenv/reporter/summary.go b/build/devenv/reporter/summary.go new file mode 100644 index 000000000..fbef209a7 --- /dev/null +++ b/build/devenv/reporter/summary.go @@ -0,0 +1,192 @@ +package reporter + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/BurntSushi/toml" +) + +// printSummary writes a human-readable summary of the environment to out. +// outTomlPath is the path to the env-out.toml produced by Store(); an empty +// string or a non-existent file is handled gracefully. +func printSummary(out io.Writer, outTomlPath string) { + if outTomlPath == "" { + return + } + + abs, err := resolveTomlPath(outTomlPath) + if err != nil { + fmt.Fprintf(out, "\nenv output: %s (not found)\n", outTomlPath) + return + } + + var raw map[string]any + if _, err := toml.DecodeFile(abs, &raw); err != nil { + fmt.Fprintf(out, "\nenv output: %s (parse error: %v)\n", abs, err) + return + } + + fmt.Fprintln(out) + fmt.Fprintf(out, "env output: %s\n", abs) + fmt.Fprintln(out, strings.Repeat("─", 60)) + + summarizeBlockchains(out, raw) + summarizeAggregators(out, raw) + summarizeVerifiers(out, raw) + summarizeIndexers(out, raw) + summarizeExecutors(out, raw) + summarizeFake(out, raw) +} + +func resolveTomlPath(path string) (string, error) { + if _, err := os.Stat(path); err == nil { + return path, nil + } + // The Store function writes relative to DefaultConfigDir ("."). + // Check the current working directory. + abs := "./" + path + if _, err := os.Stat(abs); err == nil { + return abs, nil + } + return "", fmt.Errorf("not found") +} + +// ── section helpers ─────────────────────────────────────────────────────────── + +func summarizeBlockchains(out io.Writer, raw map[string]any) { + bcs := toSlice(raw["blockchains"]) + if len(bcs) == 0 { + return + } + fmt.Fprintf(out, "chains (%d):\n", len(bcs)) + for _, item := range bcs { + m, ok := item.(map[string]any) + if !ok { + continue + } + chainID := strField(m, "chain_id") + o := subMap(m, "out") + nodes := toSlice(o["nodes"]) + for _, n := range nodes { + nm, ok := n.(map[string]any) + if !ok { + continue + } + http := strField(nm, "http_url") + ws := strField(nm, "ws_url") + fmt.Fprintf(out, " chain %s http: %s ws: %s\n", chainID, http, ws) + } + } +} + +func summarizeAggregators(out io.Writer, raw map[string]any) { + aggs := toSlice(raw["aggregators"]) + if len(aggs) == 0 { + return + } + fmt.Fprintf(out, "aggregators (%d):\n", len(aggs)) + for _, item := range aggs { + m, ok := item.(map[string]any) + if !ok { + continue + } + o := subMap(m, "out") + name := strField(m, "committee_name") + ext := strField(o, "external_https_url") + if ext == "" { + ext = strField(o, "external_http_url") + } + fmt.Fprintf(out, " %s %s\n", name, ext) + } +} + +func summarizeVerifiers(out io.Writer, raw map[string]any) { + vs := toSlice(raw["verifiers"]) + if len(vs) == 0 { + return + } + fmt.Fprintf(out, "verifiers (%d)\n", len(vs)) +} + +func summarizeIndexers(out io.Writer, raw map[string]any) { + idxs := toSlice(raw["indexer"]) + if len(idxs) == 0 { + return + } + fmt.Fprintf(out, "indexers (%d):\n", len(idxs)) + for _, item := range idxs { + m, ok := item.(map[string]any) + if !ok { + continue + } + o := subMap(m, "out") + url := strField(o, "http_url") + fmt.Fprintf(out, " %s\n", url) + } +} + +func summarizeExecutors(out io.Writer, raw map[string]any) { + exs := toSlice(raw["executor"]) + if len(exs) == 0 { + return + } + fmt.Fprintf(out, "executors (%d):\n", len(exs)) + for _, item := range exs { + m, ok := item.(map[string]any) + if !ok { + continue + } + name := strField(m, "container_name") + o := subMap(m, "out") + url := strField(o, "http_url") + fmt.Fprintf(out, " %s %s\n", name, url) + } +} + +func summarizeFake(out io.Writer, raw map[string]any) { + f, ok := raw["fake"].(map[string]any) + if !ok { + return + } + o := subMap(f, "out") + url := strField(o, "http_url") + if url != "" { + fmt.Fprintf(out, "fake: %s\n", url) + } +} + +// ── TOML decode helpers ─────────────────────────────────────────────────────── + +// toSlice normalises a TOML array-of-tables ([]any) or a single table +// (map[string]any) into a []any. +func toSlice(v any) []any { + if v == nil { + return nil + } + if s, ok := v.([]any); ok { + return s + } + if m, ok := v.(map[string]any); ok { + return []any{m} + } + return nil +} + +func subMap(m map[string]any, key string) map[string]any { + if m == nil { + return nil + } + sub, _ := m[key].(map[string]any) + return sub +} + +func strField(m map[string]any, key string) string { + if m == nil { + return "" + } + s, _ := m[key].(string) + return s +} diff --git a/build/devenv/runtime/component.go b/build/devenv/runtime/component.go index 4df8bd49b..1b8312ae4 100644 --- a/build/devenv/runtime/component.go +++ b/build/devenv/runtime/component.go @@ -40,3 +40,10 @@ type Phase4Component interface { type LogSetter interface { SetLogger(lggr zerolog.Logger) } + +// Statuser is an optional interface components may implement to report +// finer-grained internal status during execution. The runtime polls this +// asynchronously and surfaces the result in the live display footer. +type Statuser interface { + Status() string +} diff --git a/build/devenv/runtime/environment.go b/build/devenv/runtime/environment.go index 842e9c18e..1cf92acf5 100644 --- a/build/devenv/runtime/environment.go +++ b/build/devenv/runtime/environment.go @@ -15,7 +15,7 @@ import ( // NewEnvironment runs the environment startup using the global registry. func NewEnvironment(ctx context.Context, rawConfig map[string]any, logger zerolog.Logger) (map[string]any, error) { - return NewEnvironmentWithRegistry(ctx, rawConfig, global, noopEffectExecutor{}, logger) + return NewEnvironmentWithRegistry(ctx, rawConfig, global, noopEffectExecutor{}, logger, NoopReporter{}) } // NewEnvironmentWithRegistry runs the environment startup using the provided registry. @@ -37,7 +37,7 @@ func NewEnvironment(ctx context.Context, rawConfig map[string]any, logger zerolo // After all components in a phase run, the runtime collects their Effect // requests and executes them in a fixed order (CLNodeConfigEffect → // FundingEffect → JobProposalEffect) before advancing to the next phase. -func NewEnvironmentWithRegistry(ctx context.Context, rawConfig map[string]any, r *Registry, effectExecutor EffectExecutor, logger zerolog.Logger) (map[string]any, error) { +func NewEnvironmentWithRegistry(ctx context.Context, rawConfig map[string]any, r *Registry, effectExecutor EffectExecutor, logger zerolog.Logger, reporter Reporter) (map[string]any, error) { if effectExecutor == nil { effectExecutor = noopEffectExecutor{} } @@ -92,11 +92,13 @@ func NewEnvironmentWithRegistry(ctx context.Context, rawConfig map[string]any, r } comp := specific[key] if p1, ok := comp.(Phase1Component); ok { + reporter.OnStart(phase, key, comp) start := time.Now() - out, effects, err := p1.RunPhase1(ctx, rawConfig, rawConfig[key]) + out, effects, runErr := p1.RunPhase1(ctx, rawConfig, rawConfig[key]) compTimings.Record(phase, key, start, time.Now()) - if err != nil { - return nil, fmt.Errorf("phase1 %s: %w", key, err) + reporter.OnFinish(phase, key, runErr) + if runErr != nil { + return nil, fmt.Errorf("phase1 %s: %w", key, runErr) } if err := mergeNoOverwrite(accumulated, out, phase, key); err != nil { return nil, err @@ -120,11 +122,13 @@ func NewEnvironmentWithRegistry(ctx context.Context, rawConfig map[string]any, r } comp := specific[key] if p2, ok := comp.(Phase2Component); ok { + reporter.OnStart(phase, key, comp) start := time.Now() - out, effects, err := p2.RunPhase2(ctx, rawConfig, rawConfig[key], maps.Clone(phaseSnapshot)) + out, effects, runErr := p2.RunPhase2(ctx, rawConfig, rawConfig[key], maps.Clone(phaseSnapshot)) compTimings.Record(phase, key, start, time.Now()) - if err != nil { - return nil, fmt.Errorf("phase2 %s: %w", key, err) + reporter.OnFinish(phase, key, runErr) + if runErr != nil { + return nil, fmt.Errorf("phase2 %s: %w", key, runErr) } if err := mergeNoOverwrite(accumulated, out, phase, key); err != nil { return nil, err @@ -148,11 +152,13 @@ func NewEnvironmentWithRegistry(ctx context.Context, rawConfig map[string]any, r } comp := specific[key] if p3, ok := comp.(Phase3Component); ok { + reporter.OnStart(phase, key, comp) start := time.Now() - out, effects, err := p3.RunPhase3(ctx, rawConfig, rawConfig[key], maps.Clone(phaseSnapshot)) + out, effects, runErr := p3.RunPhase3(ctx, rawConfig, rawConfig[key], maps.Clone(phaseSnapshot)) compTimings.Record(phase, key, start, time.Now()) - if err != nil { - return nil, fmt.Errorf("phase3 %s: %w", key, err) + reporter.OnFinish(phase, key, runErr) + if runErr != nil { + return nil, fmt.Errorf("phase3 %s: %w", key, runErr) } if err := mergeNoOverwrite(accumulated, out, phase, key); err != nil { return nil, err @@ -176,11 +182,13 @@ func NewEnvironmentWithRegistry(ctx context.Context, rawConfig map[string]any, r } comp := specific[key] if p4, ok := comp.(Phase4Component); ok { + reporter.OnStart(phase, key, comp) start := time.Now() - out, effects, err := p4.RunPhase4(ctx, rawConfig, rawConfig[key], maps.Clone(phaseSnapshot)) + out, effects, runErr := p4.RunPhase4(ctx, rawConfig, rawConfig[key], maps.Clone(phaseSnapshot)) compTimings.Record(phase, key, start, time.Now()) - if err != nil { - return nil, fmt.Errorf("phase4 %s: %w", key, err) + reporter.OnFinish(phase, key, runErr) + if runErr != nil { + return nil, fmt.Errorf("phase4 %s: %w", key, runErr) } if err := mergeNoOverwrite(accumulated, out, phase, key); err != nil { return nil, err diff --git a/build/devenv/runtime/environment_test.go b/build/devenv/runtime/environment_test.go index 02e2d9a2c..544e63b81 100644 --- a/build/devenv/runtime/environment_test.go +++ b/build/devenv/runtime/environment_test.go @@ -14,7 +14,7 @@ import ( func runEnv(t *testing.T, r *devenvruntime.Registry, rawConfig map[string]any) (map[string]any, error) { t.Helper() - return devenvruntime.NewEnvironmentWithRegistry(context.Background(), rawConfig, r, nil, zerolog.Nop()) + return devenvruntime.NewEnvironmentWithRegistry(context.Background(), rawConfig, r, nil, zerolog.Nop(), devenvruntime.NoopReporter{}) } func compFactory(c devenvruntime.Component) devenvruntime.ComponentFactory { diff --git a/build/devenv/runtime/reporter.go b/build/devenv/runtime/reporter.go new file mode 100644 index 000000000..fda00a0a0 --- /dev/null +++ b/build/devenv/runtime/reporter.go @@ -0,0 +1,34 @@ +package devenvruntime + +// Reporter receives lifecycle events from the runtime and CLI stages. +// Implementations handle rendering; the runtime and CLI stay display-unaware. +type Reporter interface { + // Component lifecycle — called by the phased runtime for each component. + OnStart(phase int, name string, component Component) + OnFinish(phase int, name string, err error) + + // Stage lifecycle — called by the CLI for build / env / test stages. + OnStageStart(name string) + OnStageFinish(name string, err error) + + // Run hands control to the reporter so it can set up its display before + // invoking fn. Simple reporters call fn() directly on the same goroutine. + // The Bubbletea reporter starts the TUI on the main goroutine and runs fn + // in a background goroutine, bridging events via program.Send() internally. + Run(fn func() error) error + + // PrintSummary renders the post-run summary. outTomlPath may be empty if + // the environment was never started (e.g. test against a running env). + PrintSummary(outTomlPath string) +} + +// NoopReporter is a Reporter that does nothing. Used in verbose mode and +// in legacy/monolith mode where component-level events are unavailable. +type NoopReporter struct{} + +func (NoopReporter) OnStart(int, string, Component) {} +func (NoopReporter) OnFinish(int, string, error) {} +func (NoopReporter) OnStageStart(string) {} +func (NoopReporter) OnStageFinish(string, error) {} +func (NoopReporter) Run(fn func() error) error { return fn() } +func (NoopReporter) PrintSummary(string) {} From 4251e28d598fc5df3f3ce4dbd79ef8bd9ce19fa1 Mon Sep 17 00:00:00 2001 From: Will Winder Date: Thu, 18 Jun 2026 15:32:26 -0400 Subject: [PATCH 2/9] build/devenv: implement Statuser on protocol_contracts and committeeccv Adds a mutex-protected Status() string method to the protocol_contracts, committeeccv, and committeeccv_clnode components so the bubbletea TUI reporter can display fine-grained progress during the two longest phases. The status advances through named steps (e.g. "deploying chain X of N", "configuring lanes") so operators know exactly where a slow run is stuck. --- .../components/committeeccv/component.go | 33 +++++++++++++++++-- .../committeeccv/component_clnode.go | 24 ++++++++++++-- .../protocol_contracts/component.go | 21 +++++++++++- 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/build/devenv/components/committeeccv/component.go b/build/devenv/components/committeeccv/component.go index 3f84b245c..b474fb6cd 100644 --- a/build/devenv/components/committeeccv/component.go +++ b/build/devenv/components/committeeccv/component.go @@ -8,6 +8,7 @@ import ( "slices" "sort" "strconv" + "sync" "github.com/pelletier/go-toml/v2" @@ -53,7 +54,24 @@ func factory(_ map[string]any) (devenvruntime.Component, error) { return &component{}, nil } -type component struct{} +type component struct { + mu sync.Mutex + status string +} + +func (c *component) setStatus(s string) { + c.mu.Lock() + c.status = s + c.mu.Unlock() +} + +// Status implements the devenvruntime.Statuser optional interface so the TUI +// reporter can poll for fine-grained progress during the long Phase 3 setup. +func (c *component) Status() string { + c.mu.Lock() + defer c.mu.Unlock() + return c.status +} func (c *component) ValidateConfig(componentConfig any) error { _, err := decodeConfig(componentConfig) @@ -90,10 +108,11 @@ func (c *component) RunPhase3( } // Work on a copy of the shared Phase-2 environment. localEnv := *inputs.env + c.setStatus("ensuring aggregator credentials") if err := ensureAggregatorCredentials(aggregators); err != nil { return nil, nil, err } - return runPhase3Core(ctx, inputs, aggregators, verifiers, &localEnv) + return runPhase3Core(ctx, inputs, aggregators, verifiers, &localEnv, c.setStatus) } // phase3Inputs holds the decoded prior-phase outputs consumed by both the @@ -193,11 +212,13 @@ func runPhase3Core( aggregators []*services.AggregatorInput, verifiers []*committeeverifier.Input, localEnv *deployment.Environment, + setStatus func(string), ) (map[string]any, []devenvruntime.Effect, error) { // Step 1c: Deploy committee verifiers (+ resolvers) and mock receivers on chain. // These were previously deployed by the Phase-2 kitchen-sink changeset; the split // moves them here so Phase 2 deploys only protocol contracts. Must run before lane // configuration (Step 5b), which wires the committee verifiers into the lanes. + setStatus("deploying committee verifiers") if err := deployCommitteeVerifiersAndReceivers(inputs, localEnv); err != nil { return nil, nil, err } @@ -206,6 +227,7 @@ func runPhase3Core( // Phase 2, where the CommitteeVerifier resolver was not yet deployed, so token pools could // not be wired to it. Runs before lane config (Step 5b), matching the original ordering. // TODO: move to a dedicated token-transfer Phase 3 component. + setStatus("configuring token transfers") if len(inputs.impls) > 0 { if err := ccdeploy.ConfigureAllTokenTransfers(inputs.impls, inputs.selectors, localEnv, inputs.topology); err != nil { return nil, nil, fmt.Errorf("committeeccv: configure all token transfers: %w", err) @@ -213,6 +235,7 @@ func runPhase3Core( } // Step 2: Launch standalone verifier containers (reads HMAC creds from agg.Out). + setStatus("launching verifier containers") if err := committeeverifier.LaunchStandaloneVerifiers( verifiers, aggregators, committeeverifier.CommitteeAggregatorNames(inputs.topology), inputs.blockchainOutputs, inputs.jdInfra, @@ -220,6 +243,7 @@ func runPhase3Core( ); err != nil { return nil, nil, fmt.Errorf("committeeccv: failed to launch standalone verifiers: %w", err) } + setStatus("registering verifiers with JD") if err := committeeverifier.RegisterStandaloneVerifiersWithJD(ctx, verifiers, inputs.jdInfra.OffchainClient); err != nil { return nil, nil, fmt.Errorf("committeeccv: failed to register standalone verifiers with JD: %w", err) } @@ -232,6 +256,7 @@ func runPhase3Core( jobs.SyncEnvNodeIDs(inputs.jdInfra, localEnv) // Step 3: Generate shared TLS certificates from aggregator container names. + setStatus("generating TLS certificates") var sharedTLSCerts *services.TLSCertPaths if len(aggregators) > 0 { var allHostnames []string @@ -267,6 +292,7 @@ func runPhase3Core( // Step 5b: Configure lanes. This requires verifiers to be registered in JD (done above) // because ApplyVerifierConfig fetches verifier signing keys from JD by node ID. + setStatus("configuring lanes") if len(inputs.impls) > 0 && len(inputs.blockchains) > 0 { if err := ccdeploy.ConnectAllChainsCanonical(inputs.impls, inputs.blockchains, inputs.selectors, localEnv, inputs.topology); err != nil { return nil, nil, fmt.Errorf("committeeccv: configure lanes: %w", err) @@ -274,6 +300,7 @@ func runPhase3Core( } // Step 6: Generate aggregator committee configuration. + setStatus("generating aggregator configs") for _, agg := range aggregators { if agg == nil { continue @@ -301,6 +328,7 @@ func runPhase3Core( } // Step 7: Launch full aggregator containers. + setStatus("launching aggregators") for _, agg := range aggregators { if agg == nil { continue @@ -313,6 +341,7 @@ func runPhase3Core( } // Step 8: Generate verifier job specs and emit job proposal effects. + setStatus("generating verifier job specs") effects, err := buildVerifierJobSpecEffects(localEnv, verifiers, inputs.topology, inputs.obs, sharedTLSCerts, inputs.blockchainOutputs, inputs.ds) if err != nil { return nil, nil, err diff --git a/build/devenv/components/committeeccv/component_clnode.go b/build/devenv/components/committeeccv/component_clnode.go index 061d32c35..4f35ed44c 100644 --- a/build/devenv/components/committeeccv/component_clnode.go +++ b/build/devenv/components/committeeccv/component_clnode.go @@ -11,6 +11,7 @@ import ( "fmt" "math/big" "strings" + "sync" "github.com/pelletier/go-toml/v2" @@ -41,7 +42,24 @@ func clnodeFactory(_ map[string]any) (devenvruntime.Component, error) { return &clnodeComponent{}, nil } -type clnodeComponent struct{} +type clnodeComponent struct { + mu sync.Mutex + status string +} + +func (c *clnodeComponent) setStatus(s string) { + c.mu.Lock() + c.status = s + c.mu.Unlock() +} + +// Status implements the devenvruntime.Statuser optional interface so the TUI +// reporter can poll for fine-grained progress during the long Phase 3 setup. +func (c *clnodeComponent) Status() string { + c.mu.Lock() + defer c.mu.Unlock() + return c.status +} func (c *clnodeComponent) ValidateConfig(componentConfig any) error { _, err := decodeCLNodeConfig(componentConfig) @@ -76,6 +94,7 @@ func (c *clnodeComponent) RunPhase3( localEnv := *inputs.env // Step 1: Generate HMAC credentials (must precede bakeNodeSecrets). + c.setStatus("ensuring aggregator credentials") if err := ensureAggregatorCredentials(aggregators); err != nil { return nil, nil, err } @@ -83,6 +102,7 @@ func (c *clnodeComponent) RunPhase3( // Step 1b: Bake secrets into node specs, then launch and register CL nodes. // Must run before step 2 (verifier launch) so JD has the node IDs needed // by ApplyVerifierConfig when fetching CL-mode signing keys. + c.setStatus("launching CL nodes") clNodeClients, nodeIDs, err := launchCLNodes(ctx, &cfg, verifiers, aggregators, inputs.topology, inputs.blockchains, inputs.jdInfra) if err != nil { return nil, nil, fmt.Errorf("committeeccv_clnode: %w", err) @@ -91,7 +111,7 @@ func (c *clnodeComponent) RunPhase3( localEnv.NodeIDs = nodeIDs } - outputs, effects, err := runPhase3Core(ctx, inputs, aggregators, verifiers, &localEnv) + outputs, effects, err := runPhase3Core(ctx, inputs, aggregators, verifiers, &localEnv, c.setStatus) if err != nil { return nil, nil, err } diff --git a/build/devenv/components/protocol_contracts/component.go b/build/devenv/components/protocol_contracts/component.go index 1bd6ceefd..5d700690a 100644 --- a/build/devenv/components/protocol_contracts/component.go +++ b/build/devenv/components/protocol_contracts/component.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "strconv" + "sync" "github.com/rs/zerolog" zlog "github.com/rs/zerolog/log" @@ -53,7 +54,23 @@ func factory(_ map[string]any) (devenvruntime.Component, error) { } type component struct { - lggr zerolog.Logger + mu sync.Mutex + status string + lggr zerolog.Logger +} + +func (p *component) setStatus(s string) { + p.mu.Lock() + p.status = s + p.mu.Unlock() +} + +// Status implements the devenvruntime.Statuser optional interface so the TUI +// reporter can poll for fine-grained progress during the long deploy loop. +func (p *component) Status() string { + p.mu.Lock() + defer p.mu.Unlock() + return p.status } func (p *component) SetLogger(lggr zerolog.Logger) { @@ -151,6 +168,7 @@ func (p *component) RunPhase2( if nerr != nil { return nil, nil, nerr } + p.setStatus(fmt.Sprintf("deploying chain %s (%d of %d)", blockchains[i].ChainID, i+1, len(impls))) p.lggr.Info().Uint64("Selector", networkInfo.ChainSelector).Msg("Deploying chain selector") // Shift the deployer nonce intentionally so each chain gets different // contract addresses, catching bugs that assume address uniformity. @@ -204,6 +222,7 @@ func (p *component) RunPhase2( } e.DataStore = ds.Seal() + p.setStatus("finalizing") timeTrack.Record("[contracts] deployed") // Finalize CLDF: snapshot env metadata and print deployed addresses. From c9626f9dbcbd73dbb47abb9791f31f4235762489 Mon Sep 17 00:00:00 2001 From: Will Winder Date: Thu, 18 Jun 2026 15:39:27 -0400 Subject: [PATCH 3/9] build/devenv: persist last-used profile across ccv up and test invocations Save the active profile to ~/.ccv/active_config whenever ccv up or ccv test successfully loads a profile. Bare `ccv up` (no --profile arg) now reads that saved path before falling back to standard.profile, so the last profile used is automatically reused. --- build/devenv/cli/ccv.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/build/devenv/cli/ccv.go b/build/devenv/cli/ccv.go index 31ac6578d..aa7f3dcd1 100644 --- a/build/devenv/cli/ccv.go +++ b/build/devenv/cli/ccv.go @@ -233,7 +233,11 @@ func applyEnvConfig(cmd *cobra.Command, args []string) error { profilePath = positional } if profilePath == "" { - profilePath = "standard.profile" + if saved := getActiveConfig(); strings.HasSuffix(strings.TrimSpace(saved), ".profile") { + profilePath = strings.TrimSpace(saved) + } else { + profilePath = "standard.profile" + } } // --profile is mutually exclusive with --env-mode (when explicitly set). @@ -245,6 +249,7 @@ func applyEnvConfig(cmd *cobra.Command, args []string) error { if err := applyProfile(profilePath); err != nil { return err } + saveActiveConfig(profilePath) if outputFlag != "" { _ = os.Setenv("CTF_OUTPUT", outputFlag) } @@ -630,6 +635,7 @@ Examples: if err := applyProfile(profileName); err != nil { return err } + saveActiveConfig(profileName) _ = os.Setenv("CTF_OUTPUT", outputFile) _ = os.Setenv("TESTCONTAINERS_RYUK_DISABLED", "true") if err := newEnvFn(r); err != nil { From b5407050e1f5b39becd8ac17cc1232115f7f7b62 Mon Sep 17 00:00:00 2001 From: Will Winder Date: Thu, 18 Jun 2026 15:52:14 -0400 Subject: [PATCH 4/9] build/devenv: handle ctrl-c in the bubbletea TUI reporter Handle tea.KeyCtrlC in the model's Update function so the TUI exits cleanly when the user interrupts. After prog.Run() returns, os.Exit(130) kills the background environment goroutine and signals the conventional Ctrl-C exit code to the shell. --- build/devenv/reporter/bubbletea.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/build/devenv/reporter/bubbletea.go b/build/devenv/reporter/bubbletea.go index 9d33fac5f..4af39d46c 100644 --- a/build/devenv/reporter/bubbletea.go +++ b/build/devenv/reporter/bubbletea.go @@ -3,6 +3,7 @@ package reporter import ( "fmt" "io" + "os" "strings" "sync" "time" @@ -64,11 +65,12 @@ type activeComp struct { } type tuiModel struct { - log []string // completed / stage lines + log []string // completed / stage lines active map[string]*activeComp - activeOrder []string // insertion-ordered keys for stable display + activeOrder []string // insertion-ordered keys for stable display frame int done bool + cancelled bool finalErr error width int } @@ -95,6 +97,12 @@ func tickEvery(d time.Duration) tea.Cmd { func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case tea.KeyMsg: + if msg.Type == tea.KeyCtrlC { + m.cancelled = true + return m, tea.Quit + } + case tea.WindowSizeMsg: m.width = msg.Width @@ -281,6 +289,9 @@ func (r *bubbletearReporter) Run(fn func() error) error { return err } if tm, ok := m.(tuiModel); ok { + if tm.cancelled { + os.Exit(130) + } r.finalErr = tm.finalErr } return r.finalErr From 2c8c09fc726425c2f60958d74ad853a7f1cdf8b8 Mon Sep 17 00:00:00 2001 From: Will Winder Date: Thu, 18 Jun 2026 17:19:34 -0400 Subject: [PATCH 5/9] build/devenv: improve TUI active display and summary URL labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two-line rendering for in-progress components (phase prefix, aligned duration, status on a second line with └── indent) and a bright-green style to distinguish active from completed. Include aggregator and indexer URLs with labels in the final summary report. --- build/devenv/reporter/bubbletea.go | 24 ++++++++++++++---------- build/devenv/reporter/summary.go | 11 ++++++----- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/build/devenv/reporter/bubbletea.go b/build/devenv/reporter/bubbletea.go index 4af39d46c..402aa2552 100644 --- a/build/devenv/reporter/bubbletea.go +++ b/build/devenv/reporter/bubbletea.go @@ -45,12 +45,13 @@ type doneMsg struct{ err error } // ── styles ─────────────────────────────────────────────────────────────────── var ( - styleOK = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // green - styleFail = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // red - styleStage = lipgloss.NewStyle().Foreground(lipgloss.Color("4")) // blue - styleSep = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // dark gray - styleFooter = lipgloss.NewStyle().Foreground(lipgloss.Color("11")) // yellow - styleDim = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // dark gray + styleOK = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // green + styleFail = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // red + styleStage = lipgloss.NewStyle().Foreground(lipgloss.Color("4")) // blue + styleSep = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // dark gray + styleFooter = lipgloss.NewStyle().Foreground(lipgloss.Color("11")) // yellow + styleDim = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // dark gray + styleActive = lipgloss.NewStyle().Foreground(lipgloss.Color("10")) // bright green — in-progress ) var spinnerFrames = []string{"|", "/", "-", "\\"} @@ -193,12 +194,15 @@ func (m tuiModel) View() string { continue } elapsed := time.Since(cs.start).Round(time.Second) - line := fmt.Sprintf("%s %-28s %4s", spin, cs.name, elapsed) + // Line 1: spinner + phase + name + duration — same column layout as completed lines. + line1 := fmt.Sprintf("%s [%d] %-28s %6s", spin, cs.phase, cs.name, elapsed) + sb.WriteString(styleActive.Render(line1)) + sb.WriteString("\n") + // Line 2: status indented under the first char of the component name. if cs.status != "" { - line += styleFooter.Render(" | "+cs.status) + sb.WriteString(styleDim.Render(" └── " + cs.status)) + sb.WriteString("\n") } - sb.WriteString(styleDim.Render(line)) - sb.WriteString("\n") } return sb.String() diff --git a/build/devenv/reporter/summary.go b/build/devenv/reporter/summary.go index fbef209a7..7f35e6a24 100644 --- a/build/devenv/reporter/summary.go +++ b/build/devenv/reporter/summary.go @@ -95,11 +95,11 @@ func summarizeAggregators(out io.Writer, raw map[string]any) { } o := subMap(m, "out") name := strField(m, "committee_name") - ext := strField(o, "external_https_url") - if ext == "" { - ext = strField(o, "external_http_url") + url := strField(o, "external_https_url") + if url == "" { + url = strField(o, "external_http_url") } - fmt.Fprintf(out, " %s %s\n", name, ext) + fmt.Fprintf(out, " %-20s grpc: %s\n", name, url) } } @@ -122,9 +122,10 @@ func summarizeIndexers(out io.Writer, raw map[string]any) { if !ok { continue } + name := strField(m, "container_name") o := subMap(m, "out") url := strField(o, "http_url") - fmt.Fprintf(out, " %s\n", url) + fmt.Fprintf(out, " %-20s url: %s\n", name, url) } } From 1c2e3a93e12b7e59b303127023a435cb08dc52db Mon Sep 17 00:00:00 2001 From: Will Winder Date: Fri, 19 Jun 2026 09:01:06 -0400 Subject: [PATCH 6/9] build/devenv: polish TUI colors, summary formatting, and startup time Bind lipgloss styles to the real terminal renderer so colors survive stdout redirection; render checkmarks and component names in green with default-color phase/duration, remove the fake service from the summary, and append total startup time to the final report. --- build/devenv/reporter/bubbletea.go | 68 ++++++++++++++++++++---------- build/devenv/reporter/simple.go | 14 +++--- build/devenv/reporter/summary.go | 23 ++++------ 3 files changed, 63 insertions(+), 42 deletions(-) diff --git a/build/devenv/reporter/bubbletea.go b/build/devenv/reporter/bubbletea.go index 402aa2552..d768f556a 100644 --- a/build/devenv/reporter/bubbletea.go +++ b/build/devenv/reporter/bubbletea.go @@ -44,15 +44,30 @@ type doneMsg struct{ err error } // ── styles ─────────────────────────────────────────────────────────────────── -var ( - styleOK = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // green - styleFail = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // red - styleStage = lipgloss.NewStyle().Foreground(lipgloss.Color("4")) // blue - styleSep = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // dark gray - styleFooter = lipgloss.NewStyle().Foreground(lipgloss.Color("11")) // yellow - styleDim = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // dark gray - styleActive = lipgloss.NewStyle().Foreground(lipgloss.Color("10")) // bright green — in-progress -) +type tuiStyles struct { + ok lipgloss.Style + fail lipgloss.Style + stage lipgloss.Style + sep lipgloss.Style + dim lipgloss.Style + active lipgloss.Style +} + +func newSepStyle(out io.Writer) lipgloss.Style { + return lipgloss.NewRenderer(out).NewStyle().Foreground(lipgloss.Color("8")) +} + +func newStyles(out io.Writer) tuiStyles { + r := lipgloss.NewRenderer(out) + return tuiStyles{ + ok: r.NewStyle().Foreground(lipgloss.Color("2")), // green + fail: r.NewStyle().Foreground(lipgloss.Color("1")), // red + stage: r.NewStyle().Foreground(lipgloss.Color("4")), // blue + sep: newSepStyle(out), // dark gray + dim: r.NewStyle().Foreground(lipgloss.Color("8")), // dark gray + active: r.NewStyle().Foreground(lipgloss.Color("2")).Bold(true), // bold green — in-progress + } +} var spinnerFrames = []string{"|", "/", "-", "\\"} @@ -66,6 +81,7 @@ type activeComp struct { } type tuiModel struct { + styles tuiStyles log []string // completed / stage lines active map[string]*activeComp activeOrder []string // insertion-ordered keys for stable display @@ -76,8 +92,9 @@ type tuiModel struct { width int } -func newModel() tuiModel { +func newModel(out io.Writer) tuiModel { return tuiModel{ + styles: newStyles(out), active: make(map[string]*activeComp), width: 80, } @@ -115,11 +132,11 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tickEvery(100 * time.Millisecond) case stageStartedMsg: - m.log = append(m.log, styleStage.Render("── "+msg.name)) + m.log = append(m.log, m.styles.stage.Render("── "+msg.name)) case stageFinishedMsg: if msg.err != nil { - m.log = append(m.log, styleFail.Render(fmt.Sprintf("✗ %s failed: %v", msg.name, msg.err))) + m.log = append(m.log, m.styles.fail.Render(fmt.Sprintf("✗ %s failed: %v", msg.name, msg.err))) } case componentStartedMsg: @@ -144,11 +161,14 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } if msg.err != nil { - m.log = append(m.log, styleFail.Render( + m.log = append(m.log, m.styles.fail.Render( fmt.Sprintf("✗ [%d] %-28s %6s error: %v", msg.phase, msg.name, dur, msg.err))) } else { - m.log = append(m.log, styleOK.Render( - fmt.Sprintf("✓ [%d] %-28s %6s", msg.phase, msg.name, dur))) + // Green checkmark and name; phase and duration in default color. + m.log = append(m.log, m.styles.ok.Render("✓")+ + fmt.Sprintf(" [%d] ", msg.phase)+ + m.styles.ok.Render(fmt.Sprintf("%-28s", msg.name))+ + fmt.Sprintf(" %6s", dur)) } if m.done && len(m.active) == 0 { return m, tea.Quit @@ -184,7 +204,7 @@ func (m tuiModel) View() string { } sep := strings.Repeat("─", min(m.width, 60)) - sb.WriteString(styleSep.Render(sep)) + sb.WriteString(m.styles.sep.Render(sep)) sb.WriteString("\n") spin := spinnerFrames[m.frame%len(spinnerFrames)] @@ -194,13 +214,14 @@ func (m tuiModel) View() string { continue } elapsed := time.Since(cs.start).Round(time.Second) - // Line 1: spinner + phase + name + duration — same column layout as completed lines. - line1 := fmt.Sprintf("%s [%d] %-28s %6s", spin, cs.phase, cs.name, elapsed) - sb.WriteString(styleActive.Render(line1)) + // Line 1: spinner+phase in default, name in bold green, duration in default. + sb.WriteString(fmt.Sprintf("%s [%d] ", spin, cs.phase)) + sb.WriteString(m.styles.active.Render(fmt.Sprintf("%-28s", cs.name))) + sb.WriteString(fmt.Sprintf(" %6s", elapsed)) sb.WriteString("\n") // Line 2: status indented under the first char of the component name. if cs.status != "" { - sb.WriteString(styleDim.Render(" └── " + cs.status)) + sb.WriteString(m.styles.dim.Render(" └── " + cs.status)) sb.WriteString("\n") } } @@ -221,6 +242,7 @@ type bubbletearReporter struct { mu sync.Mutex program *tea.Program finalErr error + elapsed time.Duration out io.Writer stopPollers map[string]func() } @@ -280,15 +302,17 @@ func (r *bubbletearReporter) OnStageFinish(name string, err error) { } func (r *bubbletearReporter) Run(fn func() error) error { - prog := tea.NewProgram(newModel(), tea.WithOutput(r.out)) + prog := tea.NewProgram(newModel(r.out), tea.WithOutput(r.out)) r.program = prog + start := time.Now() go func() { err := fn() prog.Send(doneMsg{err: err}) }() m, err := prog.Run() + r.elapsed = time.Since(start) if err != nil { return err } @@ -302,5 +326,5 @@ func (r *bubbletearReporter) Run(fn func() error) error { } func (r *bubbletearReporter) PrintSummary(outTomlPath string) { - printSummary(r.out, outTomlPath) + printSummary(r.out, outTomlPath, r.elapsed) } diff --git a/build/devenv/reporter/simple.go b/build/devenv/reporter/simple.go index 2c46c4b59..5d87d2279 100644 --- a/build/devenv/reporter/simple.go +++ b/build/devenv/reporter/simple.go @@ -18,9 +18,10 @@ type componentStart struct { // simpleReporter writes one line per component completion to out. // No cursor movement — safe for pipes, CI, and log capture. type simpleReporter struct { - mu sync.Mutex - out io.Writer - starts map[string]componentStart // key: "phase:name" + mu sync.Mutex + out io.Writer + starts map[string]componentStart // key: "phase:name" + elapsed time.Duration } func newSimpleReporter(out io.Writer) *simpleReporter { @@ -69,9 +70,12 @@ func (r *simpleReporter) OnStageFinish(name string, err error) { } func (r *simpleReporter) Run(fn func() error) error { - return fn() + start := time.Now() + err := fn() + r.elapsed = time.Since(start) + return err } func (r *simpleReporter) PrintSummary(outTomlPath string) { - printSummary(r.out, outTomlPath) + printSummary(r.out, outTomlPath, r.elapsed) } diff --git a/build/devenv/reporter/summary.go b/build/devenv/reporter/summary.go index 7f35e6a24..707cf0b98 100644 --- a/build/devenv/reporter/summary.go +++ b/build/devenv/reporter/summary.go @@ -5,6 +5,7 @@ import ( "io" "os" "strings" + "time" "github.com/BurntSushi/toml" ) @@ -12,7 +13,7 @@ import ( // printSummary writes a human-readable summary of the environment to out. // outTomlPath is the path to the env-out.toml produced by Store(); an empty // string or a non-existent file is handled gracefully. -func printSummary(out io.Writer, outTomlPath string) { +func printSummary(out io.Writer, outTomlPath string, elapsed time.Duration) { if outTomlPath == "" { return } @@ -29,16 +30,20 @@ func printSummary(out io.Writer, outTomlPath string) { return } + sep := newSepStyle(out) fmt.Fprintln(out) + fmt.Fprintln(out, sep.Render(strings.Repeat("─", 60))) fmt.Fprintf(out, "env output: %s\n", abs) - fmt.Fprintln(out, strings.Repeat("─", 60)) summarizeBlockchains(out, raw) summarizeAggregators(out, raw) summarizeVerifiers(out, raw) summarizeIndexers(out, raw) summarizeExecutors(out, raw) - summarizeFake(out, raw) + + if elapsed > 0 { + fmt.Fprintf(out, "total: %s\n", elapsed.Round(time.Second)) + } } func resolveTomlPath(path string) (string, error) { @@ -147,18 +152,6 @@ func summarizeExecutors(out io.Writer, raw map[string]any) { } } -func summarizeFake(out io.Writer, raw map[string]any) { - f, ok := raw["fake"].(map[string]any) - if !ok { - return - } - o := subMap(f, "out") - url := strField(o, "http_url") - if url != "" { - fmt.Fprintf(out, "fake: %s\n", url) - } -} - // ── TOML decode helpers ─────────────────────────────────────────────────────── // toSlice normalises a TOML array-of-tables ([]any) or a single table From bdb59f7ceebad9a31639facf137fe53db3edd7a1 Mon Sep 17 00:00:00 2001 From: Will Winder Date: Fri, 19 Jun 2026 12:40:37 -0400 Subject: [PATCH 7/9] build/devenv: add per-status timing and log path to TUI reporter Record a timestamp on each status entry so the TUI can display how long each sub-step took, both while active and in the completed log. Surface the verbose log file path in the post-run summary. --- build/devenv/cli/ccv.go | 22 +++++------ build/devenv/reporter/bubbletea.go | 62 +++++++++++++++++++++++------- build/devenv/reporter/simple.go | 4 +- build/devenv/reporter/summary.go | 5 ++- build/devenv/runtime/reporter.go | 15 ++++---- 5 files changed, 73 insertions(+), 35 deletions(-) diff --git a/build/devenv/cli/ccv.go b/build/devenv/cli/ccv.go index aa7f3dcd1..c71f7fa97 100644 --- a/build/devenv/cli/ccv.go +++ b/build/devenv/cli/ccv.go @@ -104,7 +104,7 @@ var restartCmd = &cobra.Command{ return fmt.Errorf("failed to clean Docker resources: %w", err) } verbose, _ := cmd.Flags().GetBool("verbose") - term, cleanup, err := redirectToLogFile(verbose, "ccv-restart") + term, cleanup, logPath, err := redirectToLogFile(verbose, "ccv-restart") if err != nil { return err } @@ -112,7 +112,7 @@ var restartCmd = &cobra.Command{ r := reporter.New(verbose, term) outToml := resolveOutToml() runErr := r.Run(func() error { return newEnvFn(r) }) - r.PrintSummary(outToml) + r.PrintSummary(outToml, logPath) return runErr }, } @@ -127,7 +127,7 @@ var upCmd = &cobra.Command{ return err } verbose, _ := cmd.Flags().GetBool("verbose") - term, cleanup, err := redirectToLogFile(verbose, "ccv-up") + term, cleanup, logPath, err := redirectToLogFile(verbose, "ccv-up") if err != nil { return err } @@ -135,7 +135,7 @@ var upCmd = &cobra.Command{ r := reporter.New(verbose, term) outToml := resolveOutToml() runErr := r.Run(func() error { return newEnvFn(r) }) - r.PrintSummary(outToml) + r.PrintSummary(outToml, logPath) return runErr }, } @@ -596,7 +596,7 @@ Examples: // the OS fd level (dup2) so that subprocesses, zerolog, and fmt.Print* // calls all land in the log regardless of how they open stdout/stderr. // In verbose mode no redirect happens; the caller gets raw zerolog output. - term, cleanup, err := redirectToLogFile(verbose, "ccv-test") + term, cleanup, _, err := redirectToLogFile(verbose, "ccv-test") if err != nil { return err } @@ -727,24 +727,22 @@ func generateRunID() string { // the fancy reporter should render to (the saved real-terminal fd), and a // cleanup func that restores the original fds. In verbose mode nothing is // redirected and the returned writer is os.Stderr. -func redirectToLogFile(verbose bool, prefix string) (term *os.File, cleanup func(), err error) { +func redirectToLogFile(verbose bool, prefix string) (term *os.File, cleanup func(), logPath string, err error) { noop := func() {} if verbose { - return os.Stderr, noop, nil + return os.Stderr, noop, "", nil } - logPath := filepath.Join(util.CCVConfigDir(), fmt.Sprintf("%s-%d.log", prefix, time.Now().UnixMilli())) + logPath = filepath.Join(util.CCVConfigDir(), fmt.Sprintf("%s-%d.log", prefix, time.Now().UnixMilli())) lf, err := os.Create(logPath) if err != nil { - return nil, noop, fmt.Errorf("failed to create log file %s: %w", logPath, err) + return nil, noop, "", fmt.Errorf("failed to create log file %s: %w", logPath, err) } realStdoutFd, _ := syscall.Dup(int(os.Stdout.Fd())) realStderrFd, _ := syscall.Dup(int(os.Stderr.Fd())) realTerm := os.NewFile(uintptr(realStderrFd), "real_stderr") - fmt.Fprintf(realTerm, "log: %s\n", logPath) - _ = syscall.Dup2(int(lf.Fd()), int(os.Stdout.Fd())) _ = syscall.Dup2(int(lf.Fd()), int(os.Stderr.Fd())) @@ -755,7 +753,7 @@ func redirectToLogFile(verbose bool, prefix string) (term *os.File, cleanup func _ = realTerm.Close() _ = lf.Close() } - return realTerm, cleanupFn, nil + return realTerm, cleanupFn, logPath, nil } // resolveOutToml returns the path to the env-out.toml that Store() will have diff --git a/build/devenv/reporter/bubbletea.go b/build/devenv/reporter/bubbletea.go index d768f556a..4dfd897ff 100644 --- a/build/devenv/reporter/bubbletea.go +++ b/build/devenv/reporter/bubbletea.go @@ -73,11 +73,16 @@ var spinnerFrames = []string{"|", "/", "-", "\\"} // ── model ──────────────────────────────────────────────────────────────────── +type statusEntry struct { + text string + at time.Time +} + type activeComp struct { - phase int - name string - start time.Time - status string + phase int + name string + start time.Time + statuses []statusEntry } type tuiModel struct { @@ -147,11 +152,12 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case componentFinishedMsg: key := compKey(msg.phase, msg.name) cs, ok := m.active[key] - start := time.Now() + finishedAt := time.Now() + start := finishedAt if ok { start = cs.start } - dur := time.Since(start).Round(time.Millisecond) + dur := finishedAt.Sub(start).Round(time.Millisecond) delete(m.active, key) // Remove from ordered list. for i, k := range m.activeOrder { @@ -170,14 +176,33 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.styles.ok.Render(fmt.Sprintf("%-28s", msg.name))+ fmt.Sprintf(" %6s", dur)) } + // Append accumulated status lines with per-entry durations. + if ok { + n := len(cs.statuses) + for i, entry := range cs.statuses { + var entryDur time.Duration + if i < n-1 { + entryDur = cs.statuses[i+1].at.Sub(entry.at) + } else { + entryDur = finishedAt.Sub(entry.at) + } + prefix := " ├── " + if i == n-1 { + prefix = " └── " + } + m.log = append(m.log, m.styles.dim.Render(fmt.Sprintf("%s%-48s %6s", prefix, entry.text, entryDur.Round(time.Millisecond)))) + } + } if m.done && len(m.active) == 0 { return m, tea.Quit } case statusUpdateMsg: key := compKey(msg.phase, msg.name) - if cs, ok := m.active[key]; ok { - cs.status = msg.status + if cs, ok := m.active[key]; ok && msg.status != "" { + if n := len(cs.statuses); n == 0 || cs.statuses[n-1].text != msg.status { + cs.statuses = append(cs.statuses, statusEntry{text: msg.status, at: time.Now()}) + } } case doneMsg: @@ -219,9 +244,20 @@ func (m tuiModel) View() string { sb.WriteString(m.styles.active.Render(fmt.Sprintf("%-28s", cs.name))) sb.WriteString(fmt.Sprintf(" %6s", elapsed)) sb.WriteString("\n") - // Line 2: status indented under the first char of the component name. - if cs.status != "" { - sb.WriteString(m.styles.dim.Render(" └── " + cs.status)) + // Status lines use tree connectors: ├── for middle entries, └── for last. + for i, entry := range cs.statuses { + var entryDur time.Duration + if i < len(cs.statuses)-1 { + entryDur = cs.statuses[i+1].at.Sub(entry.at) + } else { + entryDur = time.Since(entry.at) + } + prefix := " ├── " + if i == len(cs.statuses)-1 { + prefix = " └── " + } + line := fmt.Sprintf("%s%-48s %6s", prefix, entry.text, entryDur.Round(time.Millisecond)) + sb.WriteString(m.styles.dim.Render(line)) sb.WriteString("\n") } } @@ -325,6 +361,6 @@ func (r *bubbletearReporter) Run(fn func() error) error { return r.finalErr } -func (r *bubbletearReporter) PrintSummary(outTomlPath string) { - printSummary(r.out, outTomlPath, r.elapsed) +func (r *bubbletearReporter) PrintSummary(outTomlPath, logFilePath string) { + printSummary(r.out, outTomlPath, logFilePath, r.elapsed) } diff --git a/build/devenv/reporter/simple.go b/build/devenv/reporter/simple.go index 5d87d2279..ecc7327a6 100644 --- a/build/devenv/reporter/simple.go +++ b/build/devenv/reporter/simple.go @@ -76,6 +76,6 @@ func (r *simpleReporter) Run(fn func() error) error { return err } -func (r *simpleReporter) PrintSummary(outTomlPath string) { - printSummary(r.out, outTomlPath, r.elapsed) +func (r *simpleReporter) PrintSummary(outTomlPath, logFilePath string) { + printSummary(r.out, outTomlPath, logFilePath, r.elapsed) } diff --git a/build/devenv/reporter/summary.go b/build/devenv/reporter/summary.go index 707cf0b98..ed8c45a7c 100644 --- a/build/devenv/reporter/summary.go +++ b/build/devenv/reporter/summary.go @@ -13,7 +13,7 @@ import ( // printSummary writes a human-readable summary of the environment to out. // outTomlPath is the path to the env-out.toml produced by Store(); an empty // string or a non-existent file is handled gracefully. -func printSummary(out io.Writer, outTomlPath string, elapsed time.Duration) { +func printSummary(out io.Writer, outTomlPath, logFilePath string, elapsed time.Duration) { if outTomlPath == "" { return } @@ -44,6 +44,9 @@ func printSummary(out io.Writer, outTomlPath string, elapsed time.Duration) { if elapsed > 0 { fmt.Fprintf(out, "total: %s\n", elapsed.Round(time.Second)) } + if logFilePath != "" { + fmt.Fprintf(out, "log: %s\n", logFilePath) + } } func resolveTomlPath(path string) (string, error) { diff --git a/build/devenv/runtime/reporter.go b/build/devenv/runtime/reporter.go index fda00a0a0..7c2bad8b9 100644 --- a/build/devenv/runtime/reporter.go +++ b/build/devenv/runtime/reporter.go @@ -19,16 +19,17 @@ type Reporter interface { // PrintSummary renders the post-run summary. outTomlPath may be empty if // the environment was never started (e.g. test against a running env). - PrintSummary(outTomlPath string) + // logFilePath is the path to the verbose log file, or empty in verbose mode. + PrintSummary(outTomlPath, logFilePath string) } // NoopReporter is a Reporter that does nothing. Used in verbose mode and // in legacy/monolith mode where component-level events are unavailable. type NoopReporter struct{} -func (NoopReporter) OnStart(int, string, Component) {} -func (NoopReporter) OnFinish(int, string, error) {} -func (NoopReporter) OnStageStart(string) {} -func (NoopReporter) OnStageFinish(string, error) {} -func (NoopReporter) Run(fn func() error) error { return fn() } -func (NoopReporter) PrintSummary(string) {} +func (NoopReporter) OnStart(int, string, Component) {} +func (NoopReporter) OnFinish(int, string, error) {} +func (NoopReporter) OnStageStart(string) {} +func (NoopReporter) OnStageFinish(string, error) {} +func (NoopReporter) Run(fn func() error) error { return fn() } +func (NoopReporter) PrintSummary(string, string) {} From 49c0b15b7893b6d4249d036825a97a7dc8d07f33 Mon Sep 17 00:00:00 2001 From: Will Winder Date: Fri, 19 Jun 2026 13:06:36 -0400 Subject: [PATCH 8/9] build/devenv: fix lint errors and stabilize TUI duration formatting Rename Statuser to StatusGetter to avoid misspell autocorrection, fix an unused-write lint error in simpleReporter.OnFinish, and format all TUI durations to 1 decimal place with fixed 7-char width to prevent text bouncing. --- .../components/committeeccv/component.go | 2 +- .../committeeccv/component_clnode.go | 2 +- .../protocol_contracts/component.go | 2 +- build/devenv/reporter/bubbletea.go | 43 ++++++++++--------- build/devenv/reporter/simple.go | 8 ++-- build/devenv/runtime/component.go | 9 ++-- build/devenv/runtime/reporter.go | 12 +++--- 7 files changed, 40 insertions(+), 38 deletions(-) diff --git a/build/devenv/components/committeeccv/component.go b/build/devenv/components/committeeccv/component.go index b474fb6cd..4b9c8d807 100644 --- a/build/devenv/components/committeeccv/component.go +++ b/build/devenv/components/committeeccv/component.go @@ -65,7 +65,7 @@ func (c *component) setStatus(s string) { c.mu.Unlock() } -// Status implements the devenvruntime.Statuser optional interface so the TUI +// Status implements the devenvruntime.StatusGetter optional interface so the TUI // reporter can poll for fine-grained progress during the long Phase 3 setup. func (c *component) Status() string { c.mu.Lock() diff --git a/build/devenv/components/committeeccv/component_clnode.go b/build/devenv/components/committeeccv/component_clnode.go index 4f35ed44c..11555fb9b 100644 --- a/build/devenv/components/committeeccv/component_clnode.go +++ b/build/devenv/components/committeeccv/component_clnode.go @@ -53,7 +53,7 @@ func (c *clnodeComponent) setStatus(s string) { c.mu.Unlock() } -// Status implements the devenvruntime.Statuser optional interface so the TUI +// Status implements the devenvruntime.StatusGetter optional interface so the TUI // reporter can poll for fine-grained progress during the long Phase 3 setup. func (c *clnodeComponent) Status() string { c.mu.Lock() diff --git a/build/devenv/components/protocol_contracts/component.go b/build/devenv/components/protocol_contracts/component.go index 5d700690a..883d015fb 100644 --- a/build/devenv/components/protocol_contracts/component.go +++ b/build/devenv/components/protocol_contracts/component.go @@ -65,7 +65,7 @@ func (p *component) setStatus(s string) { p.mu.Unlock() } -// Status implements the devenvruntime.Statuser optional interface so the TUI +// Status implements the devenvruntime.StatusGetter optional interface so the TUI // reporter can poll for fine-grained progress during the long deploy loop. func (p *component) Status() string { p.mu.Lock() diff --git a/build/devenv/reporter/bubbletea.go b/build/devenv/reporter/bubbletea.go index 4dfd897ff..9c021a710 100644 --- a/build/devenv/reporter/bubbletea.go +++ b/build/devenv/reporter/bubbletea.go @@ -35,11 +35,13 @@ type statusUpdateMsg struct { status string } -type stageStartedMsg struct{ name string } -type stageFinishedMsg struct { - name string - err error -} +type ( + stageStartedMsg struct{ name string } + stageFinishedMsg struct { + name string + err error + } +) type doneMsg struct{ err error } // ── styles ─────────────────────────────────────────────────────────────────── @@ -71,6 +73,12 @@ func newStyles(out io.Writer) tuiStyles { var spinnerFrames = []string{"|", "/", "-", "\\"} +// fmtDur formats a duration to 1 decimal place (e.g. 1m1.6s) for stable +// fixed-width display. Rounds to the nearest 100ms. +func fmtDur(d time.Duration) string { + return d.Round(100 * time.Millisecond).String() +} + // ── model ──────────────────────────────────────────────────────────────────── type statusEntry struct { @@ -157,7 +165,7 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if ok { start = cs.start } - dur := finishedAt.Sub(start).Round(time.Millisecond) + dur := fmtDur(finishedAt.Sub(start)) delete(m.active, key) // Remove from ordered list. for i, k := range m.activeOrder { @@ -168,13 +176,13 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if msg.err != nil { m.log = append(m.log, m.styles.fail.Render( - fmt.Sprintf("✗ [%d] %-28s %6s error: %v", msg.phase, msg.name, dur, msg.err))) + fmt.Sprintf("✗ [%d] %-28s %7s error: %v", msg.phase, msg.name, dur, msg.err))) } else { // Green checkmark and name; phase and duration in default color. m.log = append(m.log, m.styles.ok.Render("✓")+ fmt.Sprintf(" [%d] ", msg.phase)+ m.styles.ok.Render(fmt.Sprintf("%-28s", msg.name))+ - fmt.Sprintf(" %6s", dur)) + fmt.Sprintf(" %7s", dur)) } // Append accumulated status lines with per-entry durations. if ok { @@ -190,7 +198,7 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if i == n-1 { prefix = " └── " } - m.log = append(m.log, m.styles.dim.Render(fmt.Sprintf("%s%-48s %6s", prefix, entry.text, entryDur.Round(time.Millisecond)))) + m.log = append(m.log, m.styles.dim.Render(fmt.Sprintf("%s%7s %s", prefix, fmtDur(entryDur), entry.text))) } } if m.done && len(m.active) == 0 { @@ -238,11 +246,11 @@ func (m tuiModel) View() string { if !ok { continue } - elapsed := time.Since(cs.start).Round(time.Second) + elapsed := time.Since(cs.start) // Line 1: spinner+phase in default, name in bold green, duration in default. - sb.WriteString(fmt.Sprintf("%s [%d] ", spin, cs.phase)) + fmt.Fprintf(&sb, "%s [%d] ", spin, cs.phase) sb.WriteString(m.styles.active.Render(fmt.Sprintf("%-28s", cs.name))) - sb.WriteString(fmt.Sprintf(" %6s", elapsed)) + fmt.Fprintf(&sb, " %7s", fmtDur(elapsed)) sb.WriteString("\n") // Status lines use tree connectors: ├── for middle entries, └── for last. for i, entry := range cs.statuses { @@ -256,7 +264,7 @@ func (m tuiModel) View() string { if i == len(cs.statuses)-1 { prefix = " └── " } - line := fmt.Sprintf("%s%-48s %6s", prefix, entry.text, entryDur.Round(time.Millisecond)) + line := fmt.Sprintf("%s%7s %s", prefix, fmtDur(entryDur), entry.text) sb.WriteString(m.styles.dim.Render(line)) sb.WriteString("\n") } @@ -265,13 +273,6 @@ func (m tuiModel) View() string { return sb.String() } -func min(a, b int) int { - if a < b { - return a - } - return b -} - // ── reporter ───────────────────────────────────────────────────────────────── type bubbletearReporter struct { @@ -293,7 +294,7 @@ func newBubbletearReporter(out io.Writer) *bubbletearReporter { func (r *bubbletearReporter) OnStart(phase int, name string, comp devenvruntime.Component) { r.program.Send(componentStartedMsg{phase: phase, name: name}) - s, ok := comp.(devenvruntime.Statuser) + s, ok := comp.(devenvruntime.StatusGetter) if !ok { return } diff --git a/build/devenv/reporter/simple.go b/build/devenv/reporter/simple.go index ecc7327a6..10eb6141a 100644 --- a/build/devenv/reporter/simple.go +++ b/build/devenv/reporter/simple.go @@ -45,13 +45,13 @@ func (r *simpleReporter) OnFinish(phase int, name string, err error) { r.mu.Lock() defer r.mu.Unlock() k := r.key(phase, name) - cs, ok := r.starts[k] - if !ok { - cs = componentStart{phase: phase, name: name, start: time.Now()} + start := time.Now() + if cs, ok := r.starts[k]; ok { + start = cs.start } delete(r.starts, k) - dur := time.Since(cs.start).Round(time.Millisecond) + dur := time.Since(start).Round(time.Millisecond) if err != nil { fmt.Fprintf(r.out, "✗ [%d] %-28s %6s error: %v\n", phase, name, dur, err) } else { diff --git a/build/devenv/runtime/component.go b/build/devenv/runtime/component.go index 1b8312ae4..88f975ab9 100644 --- a/build/devenv/runtime/component.go +++ b/build/devenv/runtime/component.go @@ -41,9 +41,10 @@ type LogSetter interface { SetLogger(lggr zerolog.Logger) } -// Statuser is an optional interface components may implement to report -// finer-grained internal status during execution. The runtime polls this -// asynchronously and surfaces the result in the live display footer. -type Statuser interface { +// StatusGetter is an optional interface components may implement to report +// finer-grained internal status during execution. The reporter polls Status() +// at ~200ms intervals; statuses that change and revert within a single poll +// window will be missed. +type StatusGetter interface { Status() string } diff --git a/build/devenv/runtime/reporter.go b/build/devenv/runtime/reporter.go index 7c2bad8b9..b8fa924fc 100644 --- a/build/devenv/runtime/reporter.go +++ b/build/devenv/runtime/reporter.go @@ -27,9 +27,9 @@ type Reporter interface { // in legacy/monolith mode where component-level events are unavailable. type NoopReporter struct{} -func (NoopReporter) OnStart(int, string, Component) {} -func (NoopReporter) OnFinish(int, string, error) {} -func (NoopReporter) OnStageStart(string) {} -func (NoopReporter) OnStageFinish(string, error) {} -func (NoopReporter) Run(fn func() error) error { return fn() } -func (NoopReporter) PrintSummary(string, string) {} +func (NoopReporter) OnStart(int, string, Component) {} +func (NoopReporter) OnFinish(int, string, error) {} +func (NoopReporter) OnStageStart(string) {} +func (NoopReporter) OnStageFinish(string, error) {} +func (NoopReporter) Run(fn func() error) error { return fn() } +func (NoopReporter) PrintSummary(string, string) {} From 09c21a47ae8a23cb512fe00cc3251f2cf5bbb1ea Mon Sep 17 00:00:00 2001 From: Will Winder Date: Fri, 19 Jun 2026 22:36:51 -0400 Subject: [PATCH 9/9] build/devenv: show cumulative run time on active component header Add a dim parenthesised cumulative run time (e.g. "(1m27.0s)") after each active component's per-component elapsed in the TUI. Rewrite fmtDur to use seconds-only output with zero-padded sub-minute seconds so durations don't shift text as they cross character-width boundaries. --- build/devenv/reporter/bubbletea.go | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/build/devenv/reporter/bubbletea.go b/build/devenv/reporter/bubbletea.go index 9c021a710..0f0256816 100644 --- a/build/devenv/reporter/bubbletea.go +++ b/build/devenv/reporter/bubbletea.go @@ -73,10 +73,20 @@ func newStyles(out io.Writer) tuiStyles { var spinnerFrames = []string{"|", "/", "-", "\\"} -// fmtDur formats a duration to 1 decimal place (e.g. 1m1.6s) for stable -// fixed-width display. Rounds to the nearest 100ms. +// fmtDur formats a duration for stable fixed-width display: always in seconds +// (never ms), one decimal place, seconds zero-padded within minutes so the +// string length is stable (e.g. "1m09.3s" not "1m9.3s"). func fmtDur(d time.Duration) string { - return d.Round(100 * time.Millisecond).String() + if d < 0 { + d = 0 + } + d = d.Round(100 * time.Millisecond) + if d < time.Minute { + return fmt.Sprintf("%.1fs", d.Seconds()) + } + mins := int(d.Minutes()) + secs := d.Seconds() - float64(mins)*60 + return fmt.Sprintf("%dm%04.1fs", mins, secs) } // ── model ──────────────────────────────────────────────────────────────────── @@ -98,6 +108,7 @@ type tuiModel struct { log []string // completed / stage lines active map[string]*activeComp activeOrder []string // insertion-ordered keys for stable display + runStart time.Time frame int done bool cancelled bool @@ -107,9 +118,10 @@ type tuiModel struct { func newModel(out io.Writer) tuiModel { return tuiModel{ - styles: newStyles(out), - active: make(map[string]*activeComp), - width: 80, + styles: newStyles(out), + active: make(map[string]*activeComp), + runStart: time.Now(), + width: 80, } } @@ -247,10 +259,12 @@ func (m tuiModel) View() string { continue } elapsed := time.Since(cs.start) + cumulative := time.Since(m.runStart) // Line 1: spinner+phase in default, name in bold green, duration in default. fmt.Fprintf(&sb, "%s [%d] ", spin, cs.phase) sb.WriteString(m.styles.active.Render(fmt.Sprintf("%-28s", cs.name))) fmt.Fprintf(&sb, " %7s", fmtDur(elapsed)) + sb.WriteString(" " + m.styles.dim.Render(fmt.Sprintf("(%s)", fmtDur(cumulative)))) sb.WriteString("\n") // Status lines use tree connectors: ├── for middle entries, └── for last. for i, entry := range cs.statuses {