From 5baaf2747d10e96d10c5ec04716f9e31822b36bc Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 26 Aug 2025 16:01:35 +0100 Subject: [PATCH] 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 --- cli/exp_task.go | 1 + cli/exp_task_status.go | 171 +++++++++++++++++++++++ cli/exp_task_status_test.go | 270 ++++++++++++++++++++++++++++++++++++ testutil/url.go | 14 ++ 4 files changed, 456 insertions(+) create mode 100644 cli/exp_task_status.go create mode 100644 cli/exp_task_status_test.go create mode 100644 testutil/url.go diff --git a/cli/exp_task.go b/cli/exp_task.go index 860f7b954f..005138050b 100644 --- a/cli/exp_task.go +++ b/cli/exp_task.go @@ -15,6 +15,7 @@ func (r *RootCmd) tasksCommand() *serpent.Command { Children: []*serpent.Command{ r.taskList(), r.taskCreate(), + r.taskStatus(), }, } return cmd diff --git a/cli/exp_task_status.go b/cli/exp_task_status.go new file mode 100644 index 0000000000..7b4b75c1a8 --- /dev/null +++ b/cli/exp_task_status.go @@ -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} +} diff --git a/cli/exp_task_status_test.go b/cli/exp_task_status_test.go new file mode 100644 index 0000000000..6aa52ff388 --- /dev/null +++ b/cli/exp_task_status_test.go @@ -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())) +} diff --git a/testutil/url.go b/testutil/url.go new file mode 100644 index 0000000000..1b6e1caa4f --- /dev/null +++ b/testutil/url.go @@ -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 +}