Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion cmd/thv/app/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,12 +157,14 @@ func completeLogsArgs(cmd *cobra.Command, args []string, _ string) ([]string, co
}

// workloadStatusIndicator returns the status string with a visual indicator prepended
// for statuses that warrant user attention (unauthenticated, policy_stopped).
// for statuses that warrant user attention (unauthenticated, auth_retrying, policy_stopped).
// All other statuses are returned as plain strings.
func workloadStatusIndicator(status runtime.WorkloadStatus) string {
switch status {
case runtime.WorkloadStatusUnauthenticated:
return "⚠️ " + string(status)
case runtime.WorkloadStatusAuthRetrying:
return "🔄 " + string(status)
case runtime.WorkloadStatusPolicyStopped:
return "🚫 " + string(status)
case runtime.WorkloadStatusRunning, runtime.WorkloadStatusStopped, runtime.WorkloadStatusError,
Expand Down
38 changes: 38 additions & 0 deletions cmd/thv/app/common_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@
package app

import (
"strings"
"testing"

"github.com/spf13/cobra"

"github.com/stacklok/toolhive/pkg/container/runtime"
)

func TestAddFormatFlag(t *testing.T) {
Expand Down Expand Up @@ -266,3 +269,38 @@ func TestIsOIDCEnabled(t *testing.T) {
})
}
}

func TestWorkloadStatusIndicator(t *testing.T) {
t.Parallel()
tests := []struct {
name string
status runtime.WorkloadStatus
wantHas string // substring that must appear
wantExact string // if non-empty, must match exactly
}{
{"unauthenticated has ⚠️ prefix", runtime.WorkloadStatusUnauthenticated, "⚠️", ""},
{"auth_retrying has 🔄 prefix", runtime.WorkloadStatusAuthRetrying, "🔄", ""},
{"policy_stopped has 🚫 prefix", runtime.WorkloadStatusPolicyStopped, "🚫", ""},
{"running passes through plain", runtime.WorkloadStatusRunning, "", "running"},
{"stopped passes through plain", runtime.WorkloadStatusStopped, "", "stopped"},
{"unhealthy passes through plain", runtime.WorkloadStatusUnhealthy, "", "unhealthy"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := workloadStatusIndicator(tc.status)
if tc.wantExact != "" && got != tc.wantExact {
t.Errorf("workloadStatusIndicator(%q) = %q, want exact %q",
tc.status, got, tc.wantExact)
}
if tc.wantHas != "" && !strings.Contains(got, tc.wantHas) {
t.Errorf("workloadStatusIndicator(%q) = %q, want substring %q",
tc.status, got, tc.wantHas)
}
if !strings.Contains(got, string(tc.status)) {
t.Errorf("workloadStatusIndicator(%q) = %q, must include status name",
tc.status, got)
}
})
}
}
2 changes: 1 addition & 1 deletion cmd/thv/app/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ func printTextOutput(workloadList []core.Workload) {

// Print workload information
for _, c := range workloadList {
// Highlight unauthenticated and policy-stopped workloads with indicators
// Highlight unauthenticated, auth-retrying, and policy-stopped workloads with indicators
status := workloadStatusIndicator(c.Status)

// Print workload information
Expand Down
7 changes: 7 additions & 0 deletions cmd/thv/app/ui/styles.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ var (
pillUnauthed = lipgloss.NewStyle().
Background(bgWarning).Foreground(ColorYellow).
Padding(0, 1).Render("⚠ unauthed")
pillAuthRetrying = lipgloss.NewStyle().
Background(bgWarning).Foreground(ColorYellow).
Padding(0, 1).Render("🔄 retrying")

keyStyle = lipgloss.NewStyle().Foreground(ColorDim2)
portStyle = lipgloss.NewStyle().Foreground(ColorCyan).Bold(true)
Expand All @@ -97,6 +100,8 @@ func RenderStatusDot(status rt.WorkloadStatus) string {
return dotWarning
case rt.WorkloadStatusUnauthenticated:
return dotWarning
case rt.WorkloadStatusAuthRetrying:
return dotWarning
case rt.WorkloadStatusRemoving:
return dotWarning
case rt.WorkloadStatusPolicyStopped:
Expand Down Expand Up @@ -128,6 +133,8 @@ func RenderStatusPill(status rt.WorkloadStatus) string {
return pillUnknown
case rt.WorkloadStatusUnauthenticated:
return pillUnauthed
case rt.WorkloadStatusAuthRetrying:
return pillAuthRetrying
case rt.WorkloadStatusPolicyStopped:
return pillStopped
default:
Expand Down
1 change: 1 addition & 0 deletions docs/arch/02-core-concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ A **workload** is the fundamental deployment unit in ToolHive. It represents eve
- `error` - Workload encountered an error
- `unhealthy` - Workload is running but unhealthy
- `unauthenticated` - Remote workload cannot authenticate (expired tokens)
- `auth_retrying` - Remote workload's token refresh is failing transiently; monitor is still retrying until success (→ `running`) or the configured ceiling (→ `unauthenticated`)

**Implementation:**
- Interface: `pkg/workloads/manager.go`
Expand Down
14 changes: 13 additions & 1 deletion docs/arch/08-workloads-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ stateDiagram-v2
Running --> Stopping: Stop
Running --> Unhealthy: Health Failed
Running --> Unauthenticated: Auth Failed
Running --> AuthRetrying: Transient Token Refresh Failures
Running --> Stopped: Container Exit

Stopping --> Stopped: Success
Expand All @@ -31,14 +32,25 @@ stateDiagram-v2
Unauthenticated --> Starting: Re-authenticate
Unauthenticated --> Removing: Delete

AuthRetrying --> Running: Refresh Succeeds
AuthRetrying --> Unauthenticated: Ceiling Exceeded or Permanent Error

Removing --> [*]: Success
Error --> Starting: Restart
Error --> Removing: Delete
```

**States**: `pkg/container/runtime/types.go`
- `starting`, `running`, `stopping`, `stopped`
- `removing`, `error`, `unhealthy`, `unauthenticated`
- `removing`, `error`, `unhealthy`, `unauthenticated`, `auth_retrying`

The `auth_retrying` cadence and ceiling can be tuned via environment
variables on the proxy process:

- `TOOLHIVE_TOKEN_AUTH_RETRYING_TICK_INTERVAL` (default `10m`): cadence
between background refresh attempts during the AuthRetrying window.
- `TOOLHIVE_TOKEN_AUTH_RETRYING_MAX_ELAPSED` (default `24h`): ceiling
before the workload is finally marked `unauthenticated`.

## Core Operations

Expand Down
4 changes: 4 additions & 0 deletions docs/server/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions docs/server/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions docs/server/swagger.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pkg/api/v1/workload_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ type workloadListResponse struct {
type workloadStatusResponse struct {
// Current status of the workload
//nolint:lll // enums tag needed for swagger generation with --parseDependencyLevel
Status runtime.WorkloadStatus `json:"status" enums:"running,stopped,error,starting,stopping,unhealthy,removing,unknown,unauthenticated,policy_stopped"`
Status runtime.WorkloadStatus `json:"status" enums:"running,stopped,error,starting,stopping,unhealthy,removing,unknown,unauthenticated,auth_retrying,policy_stopped"`
}

// updateRequest represents the request to update an existing workload
Expand Down
Loading
Loading