feat(cli): improve exp task status --watch (#19969)

* Improves logic for `exp task status --watch` so that it will also exit
on task idle status.
* Adds workspace agent health to `exp task status` output.
This commit is contained in:
Cian Johnston
2025-09-26 10:33:31 +01:00
committed by GitHub
parent 47c92ad1d2
commit 82bebc7cc8
2 changed files with 75 additions and 57 deletions
+48 -25
View File
@@ -21,6 +21,7 @@ func (r *RootCmd) taskStatus() *serpent.Command {
[]string{
"state changed",
"status",
"healthy",
"state",
"message",
},
@@ -92,44 +93,46 @@ func (r *RootCmd) taskStatus() *serpent.Command {
return err
}
out, err := formatter.Format(ctx, toStatusRow(task))
tsr := toStatusRow(task)
out, err := formatter.Format(ctx, []taskStatusRow{tsr})
if err != nil {
return xerrors.Errorf("format task status: %w", err)
}
_, _ = fmt.Fprintln(i.Stdout, out)
if !watchArg {
if !watchArg || taskWatchIsEnded(task) {
return nil
}
lastStatus := task.Status
lastState := task.CurrentState
t := time.NewTicker(watchIntervalArg)
defer t.Stop()
// TODO: implement streaming updates instead of polling
lastStatusRow := tsr
for range t.C {
task, err := ec.TaskByID(ctx, taskID)
if err != nil {
return err
}
if lastStatus == task.Status && taskStatusEqual(lastState, task.CurrentState) {
continue
}
out, err := formatter.Format(ctx, toStatusRow(task))
if err != nil {
return xerrors.Errorf("format task status: %w", err)
}
// hack: skip the extra column header from formatter
if formatter.FormatID() != cliui.JSONFormat().ID() {
out = strings.SplitN(out, "\n", 2)[1]
}
_, _ = fmt.Fprintln(i.Stdout, out)
if task.Status == codersdk.WorkspaceStatusStopped {
// Only print if something changed
newStatusRow := toStatusRow(task)
if !taskStatusRowEqual(lastStatusRow, newStatusRow) {
out, err := formatter.Format(ctx, []taskStatusRow{newStatusRow})
if err != nil {
return xerrors.Errorf("format task status: %w", err)
}
// hack: skip the extra column header from formatter
if formatter.FormatID() != cliui.JSONFormat().ID() {
out = strings.SplitN(out, "\n", 2)[1]
}
_, _ = fmt.Fprintln(i.Stdout, out)
}
if taskWatchIsEnded(task) {
return nil
}
lastStatus = task.Status
lastState = task.CurrentState
lastStatusRow = newStatusRow
}
return nil
},
@@ -138,14 +141,20 @@ func (r *RootCmd) taskStatus() *serpent.Command {
return cmd
}
func taskStatusEqual(s1, s2 *codersdk.TaskStateEntry) bool {
if s1 == nil && s2 == nil {
func taskWatchIsEnded(task codersdk.Task) bool {
if task.Status == codersdk.WorkspaceStatusStopped {
return true
}
if s1 == nil || s2 == nil {
if task.WorkspaceAgentHealth == nil || !task.WorkspaceAgentHealth.Healthy {
return false
}
return s1.State == s2.State
if task.WorkspaceAgentLifecycle == nil || task.WorkspaceAgentLifecycle.Starting() || task.WorkspaceAgentLifecycle.ShuttingDown() {
return false
}
if task.CurrentState == nil || task.CurrentState.State == codersdk.TaskStateWorking {
return false
}
return true
}
type taskStatusRow struct {
@@ -153,22 +162,36 @@ type taskStatusRow struct {
ChangedAgo string `json:"-" table:"state changed,default_sort"`
Timestamp time.Time `json:"-" table:"-"`
TaskStatus string `json:"-" table:"status"`
Healthy bool `json:"-" table:"healthy"`
TaskState string `json:"-" table:"state"`
Message string `json:"-" table:"message"`
}
func toStatusRow(task codersdk.Task) []taskStatusRow {
func taskStatusRowEqual(r1, r2 taskStatusRow) bool {
return r1.TaskStatus == r2.TaskStatus &&
r1.Healthy == r2.Healthy &&
r1.TaskState == r2.TaskState &&
r1.Message == r2.Message
}
func toStatusRow(task codersdk.Task) taskStatusRow {
tsr := taskStatusRow{
Task: task,
ChangedAgo: time.Since(task.UpdatedAt).Truncate(time.Second).String() + " ago",
Timestamp: task.UpdatedAt,
TaskStatus: string(task.Status),
}
tsr.Healthy = task.WorkspaceAgentHealth != nil &&
task.WorkspaceAgentHealth.Healthy &&
task.WorkspaceAgentLifecycle != nil &&
!task.WorkspaceAgentLifecycle.Starting() &&
!task.WorkspaceAgentLifecycle.ShuttingDown()
if task.CurrentState != nil {
tsr.ChangedAgo = time.Since(task.CurrentState.Timestamp).Truncate(time.Second).String() + " ago"
tsr.Timestamp = task.CurrentState.Timestamp
tsr.TaskState = string(task.CurrentState.State)
tsr.Message = task.CurrentState.Message
}
return []taskStatusRow{tsr}
return tsr
}
+27 -32
View File
@@ -16,6 +16,7 @@ import (
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
@@ -63,8 +64,8 @@ func Test_TaskStatus(t *testing.T) {
},
{
args: []string{"exists"},
expectOutput: `STATE CHANGED STATUS STATE MESSAGE
0s ago running working Thinking furiously...`,
expectOutput: `STATE CHANGED STATUS HEALTHY STATE MESSAGE
0s ago running true working Thinking furiously...`,
hf: func(ctx context.Context, now time.Time) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
@@ -83,6 +84,10 @@ func Test_TaskStatus(t *testing.T) {
Timestamp: now,
Message: "Thinking furiously...",
},
WorkspaceAgentHealth: &codersdk.WorkspaceAgentHealth{
Healthy: true,
},
WorkspaceAgentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleReady),
})
default:
t.Errorf("unexpected path: %s", r.URL.Path)
@@ -93,12 +98,10 @@ func Test_TaskStatus(t *testing.T) {
{
args: []string{"exists", "--watch"},
expectOutput: `
STATE CHANGED STATUS STATE MESSAGE
4s ago running
3s ago running working Reticulating splines...
2s ago running complete Splines reticulated successfully!
2s ago stopping complete Splines reticulated successfully!
2s ago stopped complete Splines reticulated successfully!`,
STATE CHANGED STATUS HEALTHY STATE MESSAGE
4s ago running true
3s ago running true working Reticulating splines...
2s ago running true complete Splines reticulated successfully!`,
hf: func(ctx context.Context, now time.Time) func(http.ResponseWriter, *http.Request) {
var calls atomic.Int64
return func(w http.ResponseWriter, r *http.Request) {
@@ -116,13 +119,21 @@ STATE CHANGED STATUS STATE MESSAGE
Status: codersdk.WorkspaceStatusPending,
CreatedAt: now.Add(-5 * time.Second),
UpdatedAt: now.Add(-5 * time.Second),
WorkspaceAgentHealth: &codersdk.WorkspaceAgentHealth{
Healthy: true,
},
WorkspaceAgentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleReady),
})
case 1:
httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
Status: codersdk.WorkspaceStatusRunning,
CreatedAt: now.Add(-5 * time.Second),
UpdatedAt: now.Add(-4 * time.Second),
WorkspaceAgentHealth: &codersdk.WorkspaceAgentHealth{
Healthy: true,
},
WorkspaceAgentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleReady),
UpdatedAt: now.Add(-4 * time.Second),
})
case 2:
httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{
@@ -130,6 +141,10 @@ STATE CHANGED STATUS STATE MESSAGE
Status: codersdk.WorkspaceStatusRunning,
CreatedAt: now.Add(-5 * time.Second),
UpdatedAt: now.Add(-4 * time.Second),
WorkspaceAgentHealth: &codersdk.WorkspaceAgentHealth{
Healthy: true,
},
WorkspaceAgentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleReady),
CurrentState: &codersdk.TaskStateEntry{
State: codersdk.TaskStateWorking,
Timestamp: now.Add(-3 * time.Second),
@@ -142,30 +157,10 @@ STATE CHANGED STATUS STATE MESSAGE
Status: codersdk.WorkspaceStatusRunning,
CreatedAt: now.Add(-5 * time.Second),
UpdatedAt: now.Add(-4 * time.Second),
CurrentState: &codersdk.TaskStateEntry{
State: codersdk.TaskStateComplete,
Timestamp: now.Add(-2 * time.Second),
Message: "Splines reticulated successfully!",
WorkspaceAgentHealth: &codersdk.WorkspaceAgentHealth{
Healthy: true,
},
})
case 4:
httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
Status: codersdk.WorkspaceStatusStopping,
CreatedAt: now.Add(-5 * time.Second),
UpdatedAt: now.Add(-1 * time.Second),
CurrentState: &codersdk.TaskStateEntry{
State: codersdk.TaskStateComplete,
Timestamp: now.Add(-2 * time.Second),
Message: "Splines reticulated successfully!",
},
})
case 5:
httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
Status: codersdk.WorkspaceStatusStopped,
CreatedAt: now.Add(-5 * time.Second),
UpdatedAt: now,
WorkspaceAgentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleReady),
CurrentState: &codersdk.TaskStateEntry{
State: codersdk.TaskStateComplete,
Timestamp: now.Add(-2 * time.Second),