feat(cli): implement exp task status command (#19533)
Closes https://github.com/coder/internal/issues/900 - Implements `coder exp task status` - Adds `testutil.MustURL` helper
This commit is contained in:
@@ -15,6 +15,7 @@ func (r *RootCmd) tasksCommand() *serpent.Command {
|
||||
Children: []*serpent.Command{
|
||||
r.taskList(),
|
||||
r.taskCreate(),
|
||||
r.taskStatus(),
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func (r *RootCmd) taskStatus() *serpent.Command {
|
||||
var (
|
||||
client = new(codersdk.Client)
|
||||
formatter = cliui.NewOutputFormatter(
|
||||
cliui.TableFormat(
|
||||
[]taskStatusRow{},
|
||||
[]string{
|
||||
"state changed",
|
||||
"status",
|
||||
"state",
|
||||
"message",
|
||||
},
|
||||
),
|
||||
cliui.ChangeFormatterData(
|
||||
cliui.JSONFormat(),
|
||||
func(data any) (any, error) {
|
||||
rows, ok := data.([]taskStatusRow)
|
||||
if !ok {
|
||||
return nil, xerrors.Errorf("expected []taskStatusRow, got %T", data)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
return nil, xerrors.Errorf("expected exactly 1 row, got %d", len(rows))
|
||||
}
|
||||
return rows[0], nil
|
||||
},
|
||||
),
|
||||
)
|
||||
watchArg bool
|
||||
watchIntervalArg time.Duration
|
||||
)
|
||||
cmd := &serpent.Command{
|
||||
Short: "Show the status of a task.",
|
||||
Use: "status",
|
||||
Aliases: []string{"stat"},
|
||||
Options: serpent.OptionSet{
|
||||
{
|
||||
Default: "false",
|
||||
Description: "Watch the task status output. This will stream updates to the terminal until the underlying workspace is stopped.",
|
||||
Flag: "watch",
|
||||
Name: "watch",
|
||||
Value: serpent.BoolOf(&watchArg),
|
||||
},
|
||||
{
|
||||
Default: "1s",
|
||||
Description: "Interval to poll the task for updates. Only used in tests.",
|
||||
Hidden: true,
|
||||
Flag: "watch-interval",
|
||||
Name: "watch-interval",
|
||||
Value: serpent.DurationOf(&watchIntervalArg),
|
||||
},
|
||||
},
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireNArgs(1),
|
||||
r.InitClient(client),
|
||||
),
|
||||
Handler: func(i *serpent.Invocation) error {
|
||||
ctx := i.Context()
|
||||
ec := codersdk.NewExperimentalClient(client)
|
||||
identifier := i.Args[0]
|
||||
|
||||
taskID, err := uuid.Parse(identifier)
|
||||
if err != nil {
|
||||
// Try to resolve the task as a named workspace
|
||||
// TODO: right now tasks are still "workspaces" under the hood.
|
||||
// We should update this once we have a proper task model.
|
||||
ws, err := namedWorkspace(ctx, client, identifier)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
taskID = ws.ID
|
||||
}
|
||||
task, err := ec.TaskByID(ctx, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := formatter.Format(ctx, toStatusRow(task))
|
||||
if err != nil {
|
||||
return xerrors.Errorf("format task status: %w", err)
|
||||
}
|
||||
_, _ = fmt.Fprintln(i.Stdout, out)
|
||||
|
||||
if !watchArg {
|
||||
return nil
|
||||
}
|
||||
|
||||
lastStatus := task.Status
|
||||
lastState := task.CurrentState
|
||||
t := time.NewTicker(watchIntervalArg)
|
||||
defer t.Stop()
|
||||
// TODO: implement streaming updates instead of polling
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
lastStatus = task.Status
|
||||
lastState = task.CurrentState
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
formatter.AttachOptions(&cmd.Options)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func taskStatusEqual(s1, s2 *codersdk.TaskStateEntry) bool {
|
||||
if s1 == nil && s2 == nil {
|
||||
return true
|
||||
}
|
||||
if s1 == nil || s2 == nil {
|
||||
return false
|
||||
}
|
||||
return s1.State == s2.State
|
||||
}
|
||||
|
||||
type taskStatusRow struct {
|
||||
codersdk.Task `table:"-"`
|
||||
ChangedAgo string `json:"-" table:"state changed,default_sort"`
|
||||
Timestamp time.Time `json:"-" table:"-"`
|
||||
TaskStatus string `json:"-" table:"status"`
|
||||
TaskState string `json:"-" table:"state"`
|
||||
Message string `json:"-" table:"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),
|
||||
}
|
||||
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}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func Test_TaskStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
args []string
|
||||
expectOutput string
|
||||
expectError string
|
||||
hf func(context.Context, time.Time) func(http.ResponseWriter, *http.Request)
|
||||
}{
|
||||
{
|
||||
args: []string{"doesnotexist"},
|
||||
expectError: httpapi.ResourceNotFoundResponse.Message,
|
||||
hf: func(ctx context.Context, _ time.Time) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v2/users/me/workspace/doesnotexist":
|
||||
httpapi.ResourceNotFound(w)
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
args: []string{"err-fetching-workspace"},
|
||||
expectError: assert.AnError.Error(),
|
||||
hf: func(ctx context.Context, _ time.Time) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v2/users/me/workspace/err-fetching-workspace":
|
||||
httpapi.Write(ctx, w, http.StatusOK, codersdk.Workspace{
|
||||
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
||||
})
|
||||
case "/api/experimental/tasks/me/11111111-1111-1111-1111-111111111111":
|
||||
httpapi.InternalServerError(w, assert.AnError)
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
args: []string{"exists"},
|
||||
expectOutput: `STATE CHANGED STATUS STATE MESSAGE
|
||||
0s ago running 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 {
|
||||
case "/api/v2/users/me/workspace/exists":
|
||||
httpapi.Write(ctx, w, http.StatusOK, codersdk.Workspace{
|
||||
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
||||
})
|
||||
case "/api/experimental/tasks/me/11111111-1111-1111-1111-111111111111":
|
||||
httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{
|
||||
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
||||
Status: codersdk.WorkspaceStatusRunning,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
CurrentState: &codersdk.TaskStateEntry{
|
||||
State: codersdk.TaskStateWorking,
|
||||
Timestamp: now,
|
||||
Message: "Thinking furiously...",
|
||||
},
|
||||
})
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
args: []string{"exists", "--watch"},
|
||||
expectOutput: `
|
||||
STATE CHANGED STATUS STATE MESSAGE
|
||||
4s ago running
|
||||
3s ago running working Reticulating splines...
|
||||
2s ago running completed Splines reticulated successfully!
|
||||
2s ago stopping completed Splines reticulated successfully!
|
||||
2s ago stopped completed 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) {
|
||||
defer calls.Add(1)
|
||||
switch r.URL.Path {
|
||||
case "/api/v2/users/me/workspace/exists":
|
||||
httpapi.Write(ctx, w, http.StatusOK, codersdk.Workspace{
|
||||
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
||||
})
|
||||
case "/api/experimental/tasks/me/11111111-1111-1111-1111-111111111111":
|
||||
switch calls.Load() {
|
||||
case 0:
|
||||
httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{
|
||||
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
||||
Status: codersdk.WorkspaceStatusPending,
|
||||
CreatedAt: now.Add(-5 * time.Second),
|
||||
UpdatedAt: now.Add(-5 * time.Second),
|
||||
})
|
||||
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),
|
||||
})
|
||||
case 2:
|
||||
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),
|
||||
CurrentState: &codersdk.TaskStateEntry{
|
||||
State: codersdk.TaskStateWorking,
|
||||
Timestamp: now.Add(-3 * time.Second),
|
||||
Message: "Reticulating splines...",
|
||||
},
|
||||
})
|
||||
case 3:
|
||||
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),
|
||||
CurrentState: &codersdk.TaskStateEntry{
|
||||
State: codersdk.TaskStateCompleted,
|
||||
Timestamp: now.Add(-2 * time.Second),
|
||||
Message: "Splines reticulated successfully!",
|
||||
},
|
||||
})
|
||||
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.TaskStateCompleted,
|
||||
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,
|
||||
CurrentState: &codersdk.TaskStateEntry{
|
||||
State: codersdk.TaskStateCompleted,
|
||||
Timestamp: now.Add(-2 * time.Second),
|
||||
Message: "Splines reticulated successfully!",
|
||||
},
|
||||
})
|
||||
default:
|
||||
httpapi.InternalServerError(w, xerrors.New("too many calls!"))
|
||||
return
|
||||
}
|
||||
default:
|
||||
httpapi.InternalServerError(w, xerrors.Errorf("unexpected path: %q", r.URL.Path))
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
args: []string{"exists", "--output", "json"},
|
||||
expectOutput: `{
|
||||
"id": "11111111-1111-1111-1111-111111111111",
|
||||
"organization_id": "00000000-0000-0000-0000-000000000000",
|
||||
"owner_id": "00000000-0000-0000-0000-000000000000",
|
||||
"name": "",
|
||||
"template_id": "00000000-0000-0000-0000-000000000000",
|
||||
"workspace_id": null,
|
||||
"initial_prompt": "",
|
||||
"status": "running",
|
||||
"current_state": {
|
||||
"timestamp": "2025-08-26T12:34:57Z",
|
||||
"state": "working",
|
||||
"message": "Thinking furiously...",
|
||||
"uri": ""
|
||||
},
|
||||
"created_at": "2025-08-26T12:34:56Z",
|
||||
"updated_at": "2025-08-26T12:34:56Z"
|
||||
}`,
|
||||
hf: func(ctx context.Context, _ time.Time) func(w http.ResponseWriter, r *http.Request) {
|
||||
ts := time.Date(2025, 8, 26, 12, 34, 56, 0, time.UTC)
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v2/users/me/workspace/exists":
|
||||
httpapi.Write(ctx, w, http.StatusOK, codersdk.Workspace{
|
||||
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
||||
})
|
||||
case "/api/experimental/tasks/me/11111111-1111-1111-1111-111111111111":
|
||||
httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{
|
||||
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
||||
Status: codersdk.WorkspaceStatusRunning,
|
||||
CreatedAt: ts,
|
||||
UpdatedAt: ts,
|
||||
CurrentState: &codersdk.TaskStateEntry{
|
||||
State: codersdk.TaskStateWorking,
|
||||
Timestamp: ts.Add(time.Second),
|
||||
Message: "Thinking furiously...",
|
||||
},
|
||||
})
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(strings.Join(tc.args, ","), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctx = testutil.Context(t, testutil.WaitShort)
|
||||
now = time.Now().UTC() // TODO: replace with quartz
|
||||
srv = httptest.NewServer(http.HandlerFunc(tc.hf(ctx, now)))
|
||||
client = new(codersdk.Client)
|
||||
sb = strings.Builder{}
|
||||
args = []string{"exp", "task", "status", "--watch-interval", testutil.IntervalFast.String()}
|
||||
)
|
||||
|
||||
t.Cleanup(srv.Close)
|
||||
client.URL = testutil.MustURL(t, srv.URL)
|
||||
args = append(args, tc.args...)
|
||||
inv, root := clitest.New(t, args...)
|
||||
inv.Stdout = &sb
|
||||
inv.Stderr = &sb
|
||||
clitest.SetupConfig(t, client, root)
|
||||
err := inv.WithContext(ctx).Run()
|
||||
if tc.expectError == "" {
|
||||
assert.NoError(t, err)
|
||||
} else {
|
||||
assert.ErrorContains(t, err, tc.expectError)
|
||||
}
|
||||
if diff := tableDiff(tc.expectOutput, sb.String()); diff != "" {
|
||||
t.Errorf("unexpected output diff (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func tableDiff(want, got string) string {
|
||||
var gotTrimmed strings.Builder
|
||||
for _, line := range strings.Split(got, "\n") {
|
||||
_, _ = gotTrimmed.WriteString(strings.TrimRight(line, " ") + "\n")
|
||||
}
|
||||
return cmp.Diff(strings.TrimSpace(want), strings.TrimSpace(gotTrimmed.String()))
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func MustURL(t testing.TB, raw string) *url.URL {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
Reference in New Issue
Block a user