Repository: F1bonacc1/process-compose
Pull request #312: Add `--detach-on-success`
Author: login: 9999years
Created: 2025-03-17T18:29:57Z | Merged: 2025-04-07T22:29:15Z

`process-compose --detached-with-tui --detach-on-success` will act like `--detached-with-tui`, but will automatically detach if and when all processes start up succesfully.

After detaching, a table showing the started processes is printed automatically.

Closes #311

Linked issue #311: Detached TUI startup flow
When starting `process-compose`, I usually want to wait for processes to start up and then detach from the TUI. This lets me know if startup failed (and gives me an opportunity to fix the problem by restarting the processes or adjusting my configuration) and then lets me continue on to whatever I wanted to do with those processes.

However, `process-compose` does not offer a good option for this. I can start the server in `--detached` mode and then attach to it, but this requires me to detach manually when the processes succeed, or I can start it in `--detached-with-tui` mode, or I can just `sleep` and then `process-compose list --output wide`, which usually works but feels clumsy.

I propose that we add a `--detach-after-startup` switch which automatically detaches from the TUI after all processes start up successfully.

Diff:
diff --git a/src/app/table.go b/src/app/table.go
new file mode 100644
index 00000000..b74d3df2
--- /dev/null
+++ b/src/app/table.go
@@ -0,0 +1,62 @@
+package app
+
+import (
+	"fmt"
+	"github.com/f1bonacc1/process-compose/src/types"
+)
+
+// Print a list of process states in a table.
+func PrintStatesAsTable(states []types.ProcessState) {
+
+	// Create a table
+	table := []string{"PID", "NAME", "NAMESPACE", "STATUS", "AGE", "HEALTH", "RESTARTS", "EXITCODE"}
+	tableColWidth := make([]int, len(table))
+
+	for _, state := range states {
+		if len(fmt.Sprintf("%d", state.Pid)) > tableColWidth[0] {
+			tableColWidth[0] = len(fmt.Sprintf("%d", state.Pid))
+		}
+		if len(state.Name) > tableColWidth[1] {
+			tableColWidth[1] = len(state.Name)
+		}
+		if len(state.Namespace) > tableColWidth[2] {
+			tableColWidth[2] = len(state.Namespace)
+		}
+		if len(state.Status) > tableColWidth[3] {
+			tableColWidth[3] = len(state.Status)
+		}
+		if len(state.SystemTime) > tableColWidth[4] {
+			tableColWidth[4] = len(state.SystemTime)
+		}
+		if len(state.Health) > tableColWidth[5] {
+			tableColWidth[5] = len(state.Health)
+		}
+		if len(fmt.Sprintf("%d", state.Restarts)) > tableColWidth[6] {
+			tableColWidth[6] = len(fmt.Sprintf("%d", state.Restarts))
+		}
+		if len(fmt.Sprintf("%d", state.ExitCode)) > tableColWidth[7] {
+			tableColWidth[7] = len(fmt.Sprintf("%d", state.ExitCode))
+		}
+	}
+	for i, col := range table {
+		if len(col) > tableColWidth[i] {
+			tableColWidth[i] = len(col)
+		}
+	}
+	for i, col := range table {
+		fmt.Printf("%-*s   ", tableColWidth[i], col)
+	}
+	fmt.Println()
+	for _, state := range states {
+		fmt.Printf("%-*d   ", tableColWidth[0], state.Pid)
+		fmt.Printf("%-*s   ", tableColWidth[1], state.Name)
+		fmt.Printf("%-*s   ", tableColWidth[2], state.Namespace)
+		fmt.Printf("%-*s   ", tableColWidth[3], state.Status)
+		fmt.Printf("%-*s   ", tableColWidth[4], state.SystemTime)
+		fmt.Printf("%-*s   ", tableColWidth[5], state.Health)
+		fmt.Printf("%-*d   ", tableColWidth[6], state.Restarts)
+		fmt.Printf("%-*d   ", tableColWidth[7], state.ExitCode)
+		fmt.Println()
+	}
+
+}
diff --git a/src/cmd/list.go b/src/cmd/list.go
index a8da8016..519f486f 100644
--- a/src/cmd/list.go
+++ b/src/cmd/list.go
@@ -3,6 +3,7 @@ package cmd
 import (
 	"encoding/json"
 	"fmt"
+	"github.com/f1bonacc1/process-compose/src/app"
 	"github.com/f1bonacc1/process-compose/src/types"
 	"github.com/rs/zerolog/log"
 	"os"
@@ -40,7 +41,7 @@ func printStates(states *types.ProcessesState) {
 		}
 		os.Stdout.Write(b)
 	case "wide":
-		printStatesAsTable(states.States)
+		app.PrintStatesAsTable(states.States)
 	case "":
 		for _, state := range states.States {
 			fmt.Println(state.Name)
@@ -50,61 +51,6 @@ func printStates(states *types.ProcessesState) {
 	}
 }
 
-func printStatesAsTable(states []types.ProcessState) {
-
-	// Create a table
-	table := []string{"PID", "NAME", "NAMESPACE", "STATUS", "AGE", "HEALTH", "RESTARTS", "EXITCODE"}
-	tableColWidth := make([]int, len(table))
-
-	for _, state := range states {
-		if len(fmt.Sprintf("%d", state.Pid)) > tableColWidth[0] {
-			tableColWidth[0] = len(fmt.Sprintf("%d", state.Pid))
-		}
-		if len(state.Name) > tableColWidth[1] {
-			tableColWidth[1] = len(state.Name)
-		}
-		if len(state.Namespace) > tableColWidth[2] {
-			tableColWidth[2] = len(state.Namespace)
-		}
-		if len(state.Status) > tableColWidth[3] {
-			tableColWidth[3] = len(state.Status)
-		}
-		if len(state.SystemTime) > tableColWidth[4] {
-			tableColWidth[4] = len(state.SystemTime)
-		}
-		if len(state.Health) > tableColWidth[5] {
-			tableColWidth[5] = len(state.Health)
-		}
-		if len(fmt.Sprintf("%d", state.Restarts)) > tableColWidth[6] {
-			tableColWidth[6] = len(fmt.Sprintf("%d", state.Restarts))
-		}
-		if len(fmt.Sprintf("%d", state.ExitCode)) > tableColWidth[7] {
-			tableColWidth[7] = len(fmt.Sprintf("%d", state.ExitCode))
-		}
-	}
-	for i, col := range table {
-		if len(col) > tableColWidth[i] {
-			tableColWidth[i] = len(col)
-		}
-	}
-	for i, col := range table {
-		fmt.Printf("%-*s   ", tableColWidth[i], col)
-	}
-	fmt.Println()
-	for _, state := range states {
-		fmt.Printf("%-*d   ", tableColWidth[0], state.Pid)
-		fmt.Printf("%-*s   ", tableColWidth[1], state.Name)
-		fmt.Printf("%-*s   ", tableColWidth[2], state.Namespace)
-		fmt.Printf("%-*s   ", tableColWidth[3], state.Status)
-		fmt.Printf("%-*s   ", tableColWidth[4], state.SystemTime)
-		fmt.Printf("%-*s   ", tableColWidth[5], state.Health)
-		fmt.Printf("%-*d   ", tableColWidth[6], state.Restarts)
-		fmt.Printf("%-*d   ", tableColWidth[7], state.ExitCode)
-		fmt.Println()
-	}
-
-}
-
 func init() {
 	processCmd.AddCommand(listCmd)
 	rootCmd.AddCommand(listCmd)
diff --git a/src/cmd/project_runner.go b/src/cmd/project_runner.go
index 877cdee6..6c2ebbfb 100644
--- a/src/cmd/project_runner.go
+++ b/src/cmd/project_runner.go
@@ -1,6 +1,7 @@
 package cmd
 
 import (
+	"fmt"
 	"github.com/f1bonacc1/process-compose/src/app"
 	"github.com/f1bonacc1/process-compose/src/config"
 	"github.com/f1bonacc1/process-compose/src/loader"
@@ -85,6 +86,32 @@ func startTui(runner app.IProject, isAsync bool) {
 	if !*pcFlags.IsReadOnlyMode {
 		config.CreateProcCompHome()
 	}
+
+	// Starting the TUI takes some time, so if let's check if we're going to
+	// just disconnect immediately and avoid the overhead if possible. This
+	// also prevents us from flashing the TUI open for a split second.
+	//
+	// A no-op `process-compose --detached-with-tui --detach-on-success` takes
+	// ~40ms with this optimization and ~90ms without it (for my project).
+	//
+	// A 2x performance improvement is worth a special case here.
+	if *pcFlags.DetachOnSuccess {
+		states, err := runner.GetProcessesState()
+		if err != nil {
+			log.Err(err).Msgf("Failed to get process states")
+		} else if states.IsReady() {
+			// This is kind of a lie, because we've skipped starting the TUI
+			// entirely at this point, but
+			// 1. The line above this says "Starting Process Compose in
+			//    detached mode", so it makes sense to say we're "detaching" here.
+			// 2. This makes the output consistent regardless of if the TUI is
+			//    actually opened or not.
+			fmt.Println(tui.DetachOnSuccessMessage)
+			app.PrintStatesAsTable(states.States)
+			return
+		}
+	}
+
 	settings := config.NewSettings().Load()
 	tuiOptions := []tui.Option{
 		tui.WithRefreshRate(*pcFlags.RefreshRate),
@@ -92,6 +119,7 @@ func startTui(runner app.IProject, isAsync bool) {
 		tui.WithFullScreen(*pcFlags.IsTuiFullScreen),
 		tui.WithDisabledHidden(*pcFlags.HideDisabled),
 		tui.WithDisabledExitConfirm(settings.DisableExitConfirmation),
+		tui.WithDetachOnSuccess(*pcFlags.DetachOnSuccess),
 	}
 
 	tuiOptions = append(tuiOptions,
diff --git a/src/cmd/root.go b/src/cmd/root.go
index d1f7803b..7944eec4 100644
--- a/src/cmd/root.go
+++ b/src/cmd/root.go
@@ -100,6 +100,7 @@ func init() {
 	if runtime.GOOS != "windows" {
 		rootCmd.Flags().BoolVarP(pcFlags.IsDetached, "detached", "D", *pcFlags.IsDetached, "run process-compose in detached mode")
 		rootCmd.Flags().BoolVar(pcFlags.IsDetachedWithTui, "detached-with-tui", *pcFlags.IsDetachedWithTui, "run process-compose in detached mode with TUI")
+		rootCmd.Flags().BoolVar(pcFlags.DetachOnSuccess, "detach-on-success", *pcFlags.DetachOnSuccess, "detach the process-compose TUI after successful startup. Requires --detached-with-tui")
 		rootCmd.PersistentFlags().StringVarP(pcFlags.UnixSocketPath, "unix-socket", "u", config.GetUnixSocketPath(), "path to unix socket (env: "+config.EnvVarUnixSocketPath+")")
 		rootCmd.PersistentFlags().BoolVarP(pcFlags.IsUnixSocket, "use-uds", "U", *pcFlags.IsUnixSocket, "use unix domain sockets instead of tcp")
 	}
diff --git a/src/config/Flags.go b/src/config/Flags.go
index a62fb75f..e9aa5e10 100644
--- a/src/config/Flags.go
+++ b/src/config/Flags.go
@@ -73,6 +73,7 @@ type Flags struct {
 	IsTuiFullScreen   *bool
 	IsDetached        *bool
 	IsDetachedWithTui *bool
+	DetachOnSuccess   *bool
 }
 
 // NewFlags returns new configuration flags.
@@ -105,6 +106,7 @@ func NewFlags() *Flags {
 		IsDetached:        toPtr(false),
 		IsDetachedWithTui: toPtr(false),
 		IsRawLogOutput:    toPtr(false),
+		DetachOnSuccess:   toPtr(false),
 	}
 }
 
diff --git a/src/tui/proc-table.go b/src/tui/proc-table.go
index da610ca9..c264335b 100644
--- a/src/tui/proc-table.go
+++ b/src/tui/proc-table.go
@@ -4,6 +4,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"github.com/f1bonacc1/process-compose/src/app"
 	"github.com/f1bonacc1/process-compose/src/types"
 	"github.com/gdamore/tcell/v2"
 	"github.com/rivo/tview"
@@ -29,6 +30,10 @@ type tableRowValues struct {
 	exitCode  string
 }
 
+var (
+	DetachOnSuccessMessage = "All processes started successfully, detached from TUI"
+)
+
 func (pv *pcView) fillTableData() {
 	if pv.project == nil {
 		return
@@ -49,6 +54,7 @@ func (pv *pcView) fillTableData() {
 	}
 	showPass := false
 	row := 1
+	succeeded := true
 	for _, state := range states.States {
 		if !pv.isNsSelected(state.Namespace) {
 			pv.procTable.RemoveRow(row)
@@ -62,6 +68,9 @@ func (pv *pcView) fillTableData() {
 			pv.procTable.RemoveRow(row)
 			continue
 		}
+
+		succeeded = succeeded && state.IsReady()
+
 		rowVals := pv.getTableRowValues(state)
 		setRowValues(pv.procTable, row, rowVals)
 		if state.IsRunning {
@@ -106,6 +115,12 @@ func (pv *pcView) fillTableData() {
 		pv.commandModeType = commandModePassword
 		pv.redrawGrid()
 	}
+
+	if succeeded && pv.detachOnSuccess {
+		pv.handleShutDown()
+		fmt.Println(DetachOnSuccessMessage)
+		app.PrintStatesAsTable(states.States)
+	}
 }
 
 func (pv *pcView) getMaxProcHeight() int {
diff --git a/src/tui/tui_option.go b/src/tui/tui_option.go
index 0cef9539..93e56913 100644
--- a/src/tui/tui_option.go
+++ b/src/tui/tui_option.go
@@ -52,3 +52,10 @@ func WithDisabledExitConfirm(isDisabled bool) Option {
 		return nil
 	}
 }
+
+func WithDetachOnSuccess(detachOnSuccess bool) Option {
+	return func(view *pcView) error {
+		view.detachOnSuccess = detachOnSuccess
+		return nil
+	}
+}
diff --git a/src/tui/view.go b/src/tui/view.go
index 89bd87f9..cad1b7e9 100644
--- a/src/tui/view.go
+++ b/src/tui/view.go
@@ -88,6 +88,7 @@ type pcView struct {
 	isFullScreen          bool
 	isReadOnlyMode        bool
 	isExitConfirmDisabled bool
+	detachOnSuccess       bool
 }
 
 func newPcView(project app.IProject) *pcView {
diff --git a/src/types/process.go b/src/types/process.go
index 33b5c092..75dbf78a 100644
--- a/src/types/process.go
+++ b/src/types/process.go
@@ -183,18 +183,19 @@ func compareStructs(a, b interface{}) []string {
 
 func NewProcessState(proc *ProcessConfig) *ProcessState {
 	state := &ProcessState{
-		Name:       proc.ReplicaName,
-		Namespace:  proc.Namespace,
-		Status:     ProcessStatePending,
-		SystemTime: PlaceHolderValue,
-		Age:        time.Duration(0),
-		IsRunning:  false,
-		Health:     ProcessHealthUnknown,
-		Restarts:   0,
-		ExitCode:   0,
-		Mem:        0,
-		CPU:        0,
-		Pid:        0,
+		Name:           proc.ReplicaName,
+		Namespace:      proc.Namespace,
+		Status:         ProcessStatePending,
+		SystemTime:     PlaceHolderValue,
+		Age:            time.Duration(0),
+		IsRunning:      false,
+		Health:         ProcessHealthUnknown,
+		HasHealthProbe: proc.ReadinessProbe != nil || proc.LivenessProbe != nil,
+		Restarts:       0,
+		ExitCode:       0,
+		Mem:            0,
+		CPU:            0,
+		Pid:            0,
 	}
 	if proc.Disabled {
 		state.Status = ProcessStateDisabled
@@ -211,6 +212,7 @@ type ProcessState struct {
 	SystemTime       string        `json:"system_time"`
 	Age              time.Duration `json:"age" swaggertype:"primitive,integer"`
 	Health           string        `json:"is_ready"`
+	HasHealthProbe   bool          `json:"has_ready_probe"`
 	Restarts         int           `json:"restarts"`
 	ExitCode         int           `json:"exit_code"`
 	Pid              int           `json:"pid"`
@@ -231,6 +233,41 @@ type ProcessesState struct {
 	States []ProcessState `json:"data"`
 }
 
+func (p *ProcessesState) IsReady() bool {
+	for _, state := range p.States {
+		if !state.IsReady() {
+			return false
+		}
+	}
+	return true
+}
+
+// Check if a process is running and healthy.
+//
+// If `hasHealthProbe` is true, the process must be healthy to be considered
+// ready.
+func (p *ProcessState) IsReady() bool {
+	if p.Status != ProcessStateRunning &&
+		p.Status != ProcessStateForeground &&
+		p.Status != ProcessStateLaunched &&
+		p.Status != ProcessStateCompleted &&
+		p.Status != ProcessStateSkipped &&
+		p.Status != ProcessStateDisabled &&
+		p.Status != ProcessStateRestarting {
+		return false
+	} else if p.Status == ProcessStateDisabled {
+		return true
+	} else if p.HasHealthProbe && p.Health != ProcessHealthReady {
+		return false
+	} else if p.Health != ProcessHealthReady && p.Health != ProcessHealthUnknown {
+		return false
+	} else if p.ExitCode != 0 {
+		// A non-zero exit code means the process has failed.
+		return false
+	}
+	return true
+}
+
 const (
 	RestartPolicyAlways        = "always"
 	RestartPolicyOnFailure     = "on_failure"
diff --git a/src/types/process_test.go b/src/types/process_test.go
index d7ba399a..436746d0 100644
--- a/src/types/process_test.go
+++ b/src/types/process_test.go
@@ -128,3 +128,197 @@ func TestCompareProcessConfigs(t *testing.T) {
 		})
 	}
 }
+
+func TestProcessStateIsReady(t *testing.T) {
+	tests := []struct {
+		name    string
+		p       *ProcessState
+		isReady bool
+	}{
+		{
+			name: "pending, no health probe",
+			p: &ProcessState{
+				Status:         ProcessStatePending,
+				HasHealthProbe: false,
+				Health:         ProcessHealthUnknown,
+			},
+			isReady: false,
+		},
+		{
+			name: "launching, no health probe",
+			p: &ProcessState{
+				Status:         ProcessStateLaunching,
+				HasHealthProbe: false,
+				Health:         ProcessHealthUnknown,
+			},
+			isReady: false,
+		},
+		{
+			name: "restarting, exit ok, no health probe",
+			p: &ProcessState{
+				Status:         ProcessStateRestarting,
+				HasHealthProbe: false,
+				Health:         ProcessHealthUnknown,
+				ExitCode:       0,
+			},
+			isReady: true,
+		},
+		{
+			name: "restarting, exit failed, no health probe",
+			p: &ProcessState{
+				Status:         ProcessStateRestarting,
+				HasHealthProbe: false,
+				Health:         ProcessHealthUnknown,
+				ExitCode:       1,
+			},
+			isReady: false,
+		},
+		{
+			name: "terminating, no health probe",
+			p: &ProcessState{
+				Status:         ProcessStateTerminating,
+				HasHealthProbe: false,
+				Health:         ProcessHealthUnknown,
+			},
+			isReady: false,
+		},
+		{
+			name: "running, no health probe",
+			p: &ProcessState{
+				Status:         ProcessStateRunning,
+				HasHealthProbe: false,
+				Health:         ProcessHealthUnknown,
+			},
+			isReady: true,
+		},
+		{
+			name: "foreground, no health probe",
+			p: &ProcessState{
+				Status:         ProcessStateForeground,
+				HasHealthProbe: false,
+				Health:         ProcessHealthUnknown,
+			},
+			isReady: true,
+		},
+		{
+			name: "launched, no health probe",
+			p: &ProcessState{
+				Status:         ProcessStateLaunched,
+				HasHealthProbe: false,
+				Health:         ProcessHealthUnknown,
+			},
+			isReady: true,
+		},
+		{
+			name: "completed, exit success, no health probe",
+			p: &ProcessState{
+				Status:         ProcessStateCompleted,
+				HasHealthProbe: false,
+				Health:         ProcessHealthUnknown,
+				ExitCode:       0,
+			},
+			isReady: true,
+		},
+		{
+			name: "completed, exit failure, no health probe",
+			p: &ProcessState{
+				Status:         ProcessStateCompleted,
+				HasHealthProbe: false,
+				Health:         ProcessHealthUnknown,
+				ExitCode:       1,
+			},
+			isReady: false,
+		},
+		{
+			name: "skipped, no health probe",
+			p: &ProcessState{
+				Status:         ProcessStateSkipped,
+				HasHealthProbe: false,
+				Health:         ProcessHealthUnknown,
+			},
+			isReady: true,
+		},
+		{
+			name: "error, no health probe",
+			p: &ProcessState{
+				Status:         ProcessStateError,
+				HasHealthProbe: false,
+				Health:         ProcessHealthUnknown,
+			},
+			isReady: false,
+		},
+		{
+			name: "disabled, no health probe (disabled processes will only start manually)",
+			p: &ProcessState{
+				Status:         ProcessStateDisabled,
+				HasHealthProbe: false,
+				Health:         ProcessHealthUnknown,
+			},
+			isReady: true,
+		},
+		{
+			name: "running, unhealthy",
+			p: &ProcessState{
+				Status:         ProcessStateRunning,
+				HasHealthProbe: true,
+				Health:         ProcessHealthNotReady,
+			},
+			isReady: false,
+		},
+		{
+			name: "running, healthy",
+			p: &ProcessState{
+				Status:         ProcessStateRunning,
+				HasHealthProbe: true,
+				Health:         ProcessHealthReady,
+			},
+			isReady: true,
+		},
+		{
+			name: "running, no probe, unhealthy",
+			p: &ProcessState{
+				Status: ProcessStateRunning,
+				// This state probably should not be possible, but the type system does not prevent it...
+				HasHealthProbe: false,
+				Health:         ProcessHealthNotReady,
+			},
+			isReady: false,
+		},
+		{
+			name: "garbage status and health",
+			p: &ProcessState{
+				// This is garbage, but again the type system allows it.
+				Status:         "puppy",
+				HasHealthProbe: true,
+				Health:         "doggy",
+			},
+			isReady: false,
+		},
+		{
+			name: "garbage health",
+			p: &ProcessState{
+				Status:         ProcessStateRunning,
+				HasHealthProbe: true,
+				Health:         "doggy",
+			},
+			isReady: false,
+		},
+		{
+			name: "no health probe, garbage health",
+			p: &ProcessState{
+				Status:         ProcessStateRunning,
+				HasHealthProbe: false,
+				Health:         "doggy",
+			},
+			isReady: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if tt.p.IsReady() != tt.isReady {
+				t.Errorf("Expected IsReady() = %v for state %v", tt.isReady, tt.p)
+			}
+		})
+	}
+}


Reviews:
user: login: lf-
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-17T19:21:12Z
user: login: 9999years
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T22:18:54Z
user: login: F1bonacc1
body: Looks great!

There is only one small comment about the isReady func.
Thanks!
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-04T15:02:48Z
user: login: 9999years
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T18:36:21Z
user: login: F1bonacc1
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-05T19:17:46Z
user: login: 9999years
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T22:04:43Z
user: login: F1bonacc1
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-07T22:26:57Z

Review comments:
diff_hunk: @@ -231,6 +231,21 @@ type ProcessesState struct {
 	States []ProcessState `json:"data"`
 }
 
+// Check if a process is running and healthy.
+func (p *ProcessState) IsReady() bool {
+	if p.Status != ProcessStateRunning &&
+		p.Status != ProcessStateForeground &&
+		p.Status != ProcessStateLaunched &&
+		p.Status != ProcessStateCompleted &&
+		p.Status != ProcessStateSkipped {
+		return false
+	}
+	if p.Health != ProcessHealthReady && p.Health == ProcessHealthUnknown {
path: src/types/process.go
user: login: lf-
body: this conditional feels really weird, can you check this is intentional? the first part is tautologically true if the second part is true.
created_at: 2025-03-17T19:21:12Z
author_association: NONE
diff_hunk: @@ -231,6 +231,21 @@ type ProcessesState struct {
 	States []ProcessState `json:"data"`
 }
 
+// Check if a process is running and healthy.
+func (p *ProcessState) IsReady() bool {
+	if p.Status != ProcessStateRunning &&
+		p.Status != ProcessStateForeground &&
+		p.Status != ProcessStateLaunched &&
+		p.Status != ProcessStateCompleted &&
+		p.Status != ProcessStateSkipped {
+		return false
+	}
+	if p.Health != ProcessHealthReady && p.Health == ProcessHealthUnknown {
path: src/types/process.go
user: login: 9999years
body: Yeah, this was messed up. Thanks for catching this!
created_at: 2025-03-17T22:18:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -231,6 +233,36 @@ type ProcessesState struct {
 	States []ProcessState `json:"data"`
 }
 
+func (p *ProcessesState) IsReady() bool {
+	for _, state := range p.States {
+		if !state.IsReady() {
+			return false
+		}
+	}
+	return true
+}
+
+// Check if a process is running and healthy.
+//
+// If `hasHealthProbe` is true, the process must be healthy to be considered
+// ready.
+func (p *ProcessState) IsReady() bool {
path: src/types/process.go
user: login: F1bonacc1
body: A few comments and questions about this function:
1. It looks like you consider `ProcessStatePending` as a nonready state, while true in most situations. There are scenarios in which a pending process can wait for a long time (e.g. download a 600GB model).
2. There are situations in which periodic operations (e.g. delete old file every hour) will cause the process to be (most of the time) in the `ProcessStateRestarting` state. I think this is normal and should not return `false`.
3. Will you have time to add a few unit tests to this function? I am concerned that this logic might get unintentionally broken in the future.
created_at: 2025-04-04T14:51:45Z
author_association: OWNER
diff_hunk: @@ -231,6 +233,36 @@ type ProcessesState struct {
 	States []ProcessState `json:"data"`
 }
 
+func (p *ProcessesState) IsReady() bool {
+	for _, state := range p.States {
+		if !state.IsReady() {
+			return false
+		}
+	}
+	return true
+}
+
+// Check if a process is running and healthy.
+//
+// If `hasHealthProbe` is true, the process must be healthy to be considered
+// ready.
+func (p *ProcessState) IsReady() bool {
path: src/types/process.go
user: login: 9999years
body: > It looks like you consider `ProcessStatePending` as a nonready state, while true in most situations. There are scenarios in which a pending process can wait for a long time (e.g. download a 600GB model).

This seems correct to me. If I want to start the processes and then do something else that uses them, I want to wait until the model is fully downloaded before I try to use it.

> There are situations in which periodic operations (e.g. delete old file every hour) will cause the process to be (most of the time) in the `ProcessStateRestarting` state. I think this is normal and should not return false.

Interesting, like this?

```yaml
processes:
  delete_old_files:
    exec:
      command: delete-old-files
    availability:
      restart: always
      backoff_seconds: 3600 # Run hourly
```

I think we can mark restarting as OK, at least as long as the health check is still mandatory if it's present.

> Will you have time to add a few unit tests to this function? I am concerned that this logic might get unintentionally broken in the future.

Yeah, for sure, thanks for pointing this out!
created_at: 2025-04-04T18:36:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -231,6 +233,36 @@ type ProcessesState struct {
 	States []ProcessState `json:"data"`
 }
 
+func (p *ProcessesState) IsReady() bool {
+	for _, state := range p.States {
+		if !state.IsReady() {
+			return false
+		}
+	}
+	return true
+}
+
+// Check if a process is running and healthy.
+//
+// If `hasHealthProbe` is true, the process must be healthy to be considered
+// ready.
+func (p *ProcessState) IsReady() bool {
path: src/types/process.go
user: login: F1bonacc1
body: > > It looks like you consider `ProcessStatePending` as a nonready state, while true in most situations. There are scenarios in which a pending process can wait for a long time (e.g. download a 600GB model).
> 
> This seems correct to me. If I want to start the processes and then do something else that uses them, I want to wait until the model is fully downloaded before I try to use it.

That's true. I guess it can be a matter of preference. Depends on how we treat this feature:
1. Make sure that there are no errors before dettaching. Or
2. Wait for everything to be running in its final state.

I am OK with option number 2, which means a Pending process stays as non-ready.

> 
> > There are situations in which periodic operations (e.g. delete old file every hour) will cause the process to be (most of the time) in the `ProcessStateRestarting` state. I think this is normal and should not return false.
> 
> Interesting, like this?
> 
> ```yaml
> processes:
>   delete_old_files:
>     exec:
>       command: delete-old-files
>     availability:
>       restart: always
>       backoff_seconds: 3600 # Run hourly
> ```
> 

Yes, exactly like that.

> I think we can mark restarting as OK, at least as long as the health check is still mandatory if it's present.
> 
> > Will you have time to add a few unit tests to this function? I am concerned that this logic might get unintentionally broken in the future.
> 
> Yeah, for sure, thanks for pointing this out!


created_at: 2025-04-05T19:17:45Z
author_association: OWNER
diff_hunk: @@ -231,6 +233,36 @@ type ProcessesState struct {
 	States []ProcessState `json:"data"`
 }
 
+func (p *ProcessesState) IsReady() bool {
+	for _, state := range p.States {
+		if !state.IsReady() {
+			return false
+		}
+	}
+	return true
+}
+
+// Check if a process is running and healthy.
+//
+// If `hasHealthProbe` is true, the process must be healthy to be considered
+// ready.
+func (p *ProcessState) IsReady() bool {
path: src/types/process.go
user: login: 9999years
body: OK, I've made changes:
- Added unit tests.
- "Restarting" is now OK
- `ExitCode != 0` is now not OK
created_at: 2025-04-07T22:04:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -231,6 +233,41 @@ type ProcessesState struct {
 	States []ProcessState `json:"data"`
 }
 
+func (p *ProcessesState) IsReady() bool {
+	for _, state := range p.States {
+		if !state.IsReady() {
+			return false
+		}
+	}
+	return true
+}
+
+// Check if a process is running and healthy.
+//
+// If `hasHealthProbe` is true, the process must be healthy to be considered
+// ready.
+func (p *ProcessState) IsReady() bool {
+	if p.Status != ProcessStateRunning &&
+		p.Status != ProcessStateForeground &&
+		p.Status != ProcessStateLaunched &&
+		p.Status != ProcessStateCompleted &&
+		p.Status != ProcessStateSkipped &&
+		p.Status != ProcessStateDisabled &&
+		p.Status != ProcessStateRestarting {
+		return false
+	} else if p.Status == ProcessStateDisabled {
+		return true
+	} else if p.HasHealthProbe && p.Health != ProcessHealthReady {
+		return false
+	} else if p.Health != ProcessHealthReady && p.Health != ProcessHealthUnknown {
+		return false
+	} else if p.ExitCode != 0 {
path: src/types/process.go
user: login: F1bonacc1
body: Good addition!
created_at: 2025-04-07T22:26:57Z
author_association: OWNER

PR comments:
user: login: F1bonacc1
created_at: 2025-03-18T17:31:31Z
body: 
> * Is `--detach-on-startup` the right name for this option?

How about `--detach-on-success`?

> * Should we print a message like "Detached from TUI, all processes started succesfully" after detaching?

Yeah, I think that's a good idea.


author_association: OWNER
user: login: 9999years
created_at: 2025-03-18T17:45:52Z
body: Renamed `--detach-on-startup` to `--detach-on-success`, added a message when detaching! Thanks for the quick reply :)
author_association: CONTRIBUTOR
user: login: joeblew999
created_at: 2025-04-03T07:39:56Z
body: Oh I need this too :) 

thanks @9999years 
author_association: NONE
user: login: 9999years
created_at: 2025-04-04T01:05:05Z
body: I've added a check if the project is already running before starting the TUI for a 2x speedup. (26x speedup without #321.)
author_association: CONTRIBUTOR
user: login: sonarqubecloud[bot]
created_at: 2025-04-07T22:04:24Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=F1bonacc1_process-compose&pullRequest=312) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [3 New issues](https://sonarcloud.io/project/issues?id=F1bonacc1_process-compose&pullRequest=312&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=F1bonacc1_process-compose&pullRequest=312&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=F1bonacc1_process-compose&pullRequest=312&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=F1bonacc1_process-compose&pullRequest=312&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=F1bonacc1_process-compose&pullRequest=312&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=F1bonacc1_process-compose&pullRequest=312)


author_association: NONE
user: login: 9999years
created_at: 2025-04-07T22:05:14Z
body: - Added unit tests
- Made `ProcessStateRestarting` show as ready (as long as there's not a failing health check or exit code)
- Made `ExitCode != 0` show as not ready
author_association: CONTRIBUTOR
user: login: F1bonacc1
created_at: 2025-04-07T22:29:09Z
body: Great!
Thank you!
author_association: OWNER