From de4d5f054e6c744901a6eefbfcee257ea1ebd3e5 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 9 Feb 2026 15:17:23 +0000 Subject: [PATCH 01/10] feat(cli): add `coder task pause` command Add a new CLI command to pause a running task. The command accepts a task identifier (UUID, name, or owner/name format) and triggers a workspace build transition to stop the task. Features: - Interactive confirmation prompt with --yes flag to bypass - Build progress streaming when a workspace build is returned - Supports task resolution by UUID, name, or owner/name Includes unit tests with mock HTTP server covering happy paths, error handling, prompt confirmation/decline, and an integration test in the full task lifecycle sequence. Closes coder/internal#1263 --- cli/task.go | 1 + cli/task_pause.go | 84 ++++++++ cli/task_pause_test.go | 223 ++++++++++++++++++++ cli/task_test.go | 17 ++ cli/testdata/coder_task_--help.golden | 1 + cli/testdata/coder_task_pause_--help.golden | 21 ++ 6 files changed, 347 insertions(+) create mode 100644 cli/task_pause.go create mode 100644 cli/task_pause_test.go create mode 100644 cli/testdata/coder_task_pause_--help.golden diff --git a/cli/task.go b/cli/task.go index 865d1869bf850..97aa85d4f5de4 100644 --- a/cli/task.go +++ b/cli/task.go @@ -17,6 +17,7 @@ func (r *RootCmd) tasksCommand() *serpent.Command { r.taskDelete(), r.taskList(), r.taskLogs(), + r.taskPause(), r.taskSend(), r.taskStatus(), }, diff --git a/cli/task_pause.go b/cli/task_pause.go new file mode 100644 index 0000000000000..f075d41201385 --- /dev/null +++ b/cli/task_pause.go @@ -0,0 +1,84 @@ +package cli + +import ( + "fmt" + "time" + + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/pretty" + "github.com/coder/serpent" +) + +func (r *RootCmd) taskPause() *serpent.Command { + cmd := &serpent.Command{ + Use: "pause ", + Short: "Pause a task", + Long: FormatExamples( + Example{ + Description: "Pause a task by name.", + Command: "coder task pause my-task", + }, + Example{ + Description: "Pause a task without confirmation.", + Command: "coder task pause my-task --yes", + }, + ), + Middleware: serpent.Chain( + serpent.RequireNArgs(1), + ), + Options: serpent.OptionSet{ + cliui.SkipPromptOption(), + }, + Handler: func(inv *serpent.Invocation) error { + ctx := inv.Context() + client, err := r.InitClient(inv) + if err != nil { + return err + } + + task, err := client.TaskByIdentifier(ctx, inv.Args[0]) + if err != nil { + return xerrors.Errorf("resolve task %q: %w", inv.Args[0], err) + } + + display := fmt.Sprintf("%s/%s", task.OwnerName, task.Name) + + _, err = cliui.Prompt(inv, cliui.PromptOptions{ + Text: fmt.Sprintf("Pause task %s?", pretty.Sprint(cliui.DefaultStyles.Code, display)), + IsConfirm: true, + Default: cliui.ConfirmNo, + }) + if err != nil { + return err + } + + resp, err := client.PauseTask(ctx, task.OwnerName, task.ID) + if err != nil { + return xerrors.Errorf("pause task %q: %w", display, err) + } + + if resp.WorkspaceBuild != nil { + err = cliui.WorkspaceBuild(ctx, inv.Stdout, client, resp.WorkspaceBuild.ID) + if err != nil { + return xerrors.Errorf("watch pause build for task %q: %w", display, err) + } + } else { + cliui.Warn(inv.Stderr, + "Pause was accepted but no workspace build was returned.", + "The task may not be fully paused yet.", + ) + } + + _, _ = fmt.Fprintf( + inv.Stdout, + "\nThe %s task has been paused at %s!\n", + cliui.Keyword(task.Name), + cliui.Timestamp(time.Now()), + ) + return nil + }, + } + return cmd +} diff --git a/cli/task_pause_test.go b/cli/task_pause_test.go new file mode 100644 index 0000000000000..49b3298095304 --- /dev/null +++ b/cli/task_pause_test.go @@ -0,0 +1,223 @@ +package cli_test + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "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/pty/ptytest" + "github.com/coder/coder/v2/testutil" +) + +func TestExpTaskPause(t *testing.T) { + t.Parallel() + + type testCase struct { + name string + args []string + promptYes bool + promptNo bool + wantErr string + wantPausedMsg bool + buildHandler func() http.HandlerFunc + } + + const ( + id1 = "11111111-1111-1111-1111-111111111111" + id2 = "22222222-2222-2222-2222-222222222222" + id3 = "33333333-3333-3333-3333-333333333333" + ) + + cases := []testCase{ + { + name: "OK_ByName", + args: []string{"my-task"}, + promptYes: true, + buildHandler: func() http.HandlerFunc { + taskID := uuid.MustParse(id1) + return func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v2/tasks/me/my-task": + httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Task{ + ID: taskID, + Name: "my-task", + OwnerName: "me", + }) + case r.Method == http.MethodPost && r.URL.Path == "/api/experimental/tasks/me/"+id1+"/pause": + httpapi.Write(r.Context(), w, http.StatusAccepted, codersdk.PauseTaskResponse{}) + default: + httpapi.InternalServerError(w, xerrors.New("unwanted path: "+r.Method+" "+r.URL.Path)) + } + } + }, + wantPausedMsg: true, + }, + { + name: "OK_ByUUID", + args: []string{id2}, + promptYes: true, + buildHandler: func() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v2/tasks/me/"+id2: + httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Task{ + ID: uuid.MustParse(id2), + OwnerName: "me", + Name: "uuid-task", + }) + case r.Method == http.MethodPost && r.URL.Path == "/api/experimental/tasks/me/"+id2+"/pause": + httpapi.Write(r.Context(), w, http.StatusAccepted, codersdk.PauseTaskResponse{}) + default: + httpapi.InternalServerError(w, xerrors.New("unwanted path: "+r.Method+" "+r.URL.Path)) + } + } + }, + wantPausedMsg: true, + }, + { + name: "YesFlag", + args: []string{"--yes", "my-task"}, + buildHandler: func() http.HandlerFunc { + taskID := uuid.MustParse(id3) + return func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v2/tasks/me/my-task": + httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Task{ + ID: taskID, + Name: "my-task", + OwnerName: "me", + }) + case r.Method == http.MethodPost && r.URL.Path == "/api/experimental/tasks/me/"+id3+"/pause": + httpapi.Write(r.Context(), w, http.StatusAccepted, codersdk.PauseTaskResponse{}) + default: + httpapi.InternalServerError(w, xerrors.New("unwanted path: "+r.Method+" "+r.URL.Path)) + } + } + }, + wantPausedMsg: true, + }, + { + name: "ResolveError", + args: []string{"doesnotexist"}, + wantErr: "resolve task", + buildHandler: func() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v2/tasks/me/doesnotexist": + httpapi.Write(r.Context(), w, http.StatusNotFound, codersdk.Response{ + Message: "Task not found.", + }) + default: + httpapi.InternalServerError(w, xerrors.New("unwanted path: "+r.Method+" "+r.URL.Path)) + } + } + }, + }, + { + name: "PauseError", + args: []string{"bad-task"}, + promptYes: true, + wantErr: "pause task", + buildHandler: func() http.HandlerFunc { + taskID := uuid.MustParse(id1) + return func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v2/tasks/me/bad-task": + httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Task{ + ID: taskID, + Name: "bad-task", + OwnerName: "me", + }) + case r.Method == http.MethodPost && r.URL.Path == "/api/experimental/tasks/me/"+id1+"/pause": + httpapi.Write(r.Context(), w, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error.", + Detail: "boom", + }) + default: + httpapi.InternalServerError(w, xerrors.New("unwanted path: "+r.Method+" "+r.URL.Path)) + } + } + }, + }, + { + name: "PromptDeclined", + args: []string{"my-task"}, + promptNo: true, + wantErr: "canceled", + buildHandler: func() http.HandlerFunc { + taskID := uuid.MustParse(id1) + return func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v2/tasks/me/my-task": + httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Task{ + ID: taskID, + Name: "my-task", + OwnerName: "me", + }) + default: + httpapi.InternalServerError(w, xerrors.New("unwanted path: "+r.Method+" "+r.URL.Path)) + } + } + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitMedium) + + srv := httptest.NewServer(tc.buildHandler()) + t.Cleanup(srv.Close) + + client := codersdk.New(testutil.MustURL(t, srv.URL)) + + args := append([]string{"task", "pause"}, tc.args...) + inv, root := clitest.New(t, args...) + inv = inv.WithContext(ctx) + clitest.SetupConfig(t, client, root) + + var runErr error + var outBuf bytes.Buffer + if tc.promptYes || tc.promptNo { + pty := ptytest.New(t).Attach(inv) + w := clitest.StartWithWaiter(t, inv) + pty.ExpectMatchContext(ctx, "Pause task") + if tc.promptYes { + pty.WriteLine("yes") + } else { + pty.WriteLine("no") + } + if tc.wantPausedMsg { + pty.ExpectMatchContext(ctx, "has been paused") + } + runErr = w.Wait() + } else { + inv.Stdout = &outBuf + inv.Stderr = &outBuf + runErr = inv.Run() + } + + if tc.wantErr != "" { + require.ErrorContains(t, runErr, tc.wantErr) + } else { + require.NoError(t, runErr) + } + + // For non-PTY tests, verify output from the buffer. + if tc.wantPausedMsg && !tc.promptYes && !tc.promptNo { + output := outBuf.String() + require.Contains(t, output, "has been paused") + } + }) + } +} diff --git a/cli/task_test.go b/cli/task_test.go index 8c5bdf9b0adf6..6347595fc1fd9 100644 --- a/cli/task_test.go +++ b/cli/task_test.go @@ -120,6 +120,23 @@ func Test_Tasks(t *testing.T) { require.Equal(t, logs[2].Type, codersdk.TaskLogTypeOutput, "third message should be an output") }, }, + { + name: "pause task", + cmdArgs: []string{"task", "pause", taskName, "--yes"}, + assertFn: func(stdout string, userClient *codersdk.Client) { + require.Contains(t, stdout, "has been paused", "pause output should confirm task was paused") + }, + }, + { + name: "get task status after pause", + cmdArgs: []string{"task", "status", taskName, "--output", "json"}, + assertFn: func(stdout string, userClient *codersdk.Client) { + var task codersdk.Task + require.NoError(t, json.NewDecoder(strings.NewReader(stdout)).Decode(&task), "should unmarshal task status") + require.Equal(t, taskName, task.Name, "task name should match") + require.Equal(t, codersdk.TaskStatusPaused, task.Status, "task should be paused") + }, + }, { name: "delete task", cmdArgs: []string{"task", "delete", taskName, "--yes"}, diff --git a/cli/testdata/coder_task_--help.golden b/cli/testdata/coder_task_--help.golden index c6fa004de06af..2b912746b4bcc 100644 --- a/cli/testdata/coder_task_--help.golden +++ b/cli/testdata/coder_task_--help.golden @@ -12,6 +12,7 @@ SUBCOMMANDS: delete Delete tasks list List tasks logs Show a task's logs + pause Pause a task send Send input to a task status Show the status of a task. diff --git a/cli/testdata/coder_task_pause_--help.golden b/cli/testdata/coder_task_pause_--help.golden new file mode 100644 index 0000000000000..5fb1f10645a99 --- /dev/null +++ b/cli/testdata/coder_task_pause_--help.golden @@ -0,0 +1,21 @@ +coder v0.0.0-devel + +USAGE: + coder task pause [flags] + + Pause a task + + - Pause a task by name.: + + $ coder task pause my-task + + - Pause a task without confirmation.: + + $ coder task pause my-task --yes + +OPTIONS: + -y, --yes bool + Bypass confirmation prompts. + +——— +Run `coder --help` for a list of global options. From e0944883e7c1b8f29166a9995aadd7b440016ecb Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 9 Feb 2026 15:26:15 +0000 Subject: [PATCH 02/10] chore: add generated docs for task pause command --- docs/manifest.json | 5 +++++ docs/reference/cli/task.md | 1 + docs/reference/cli/task_pause.md | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+) create mode 100644 docs/reference/cli/task_pause.md diff --git a/docs/manifest.json b/docs/manifest.json index d505a7305a0fb..a1faf1a4b7fac 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -2009,6 +2009,11 @@ "description": "Show a task's logs", "path": "reference/cli/task_logs.md" }, + { + "title": "task pause", + "description": "Pause a task", + "path": "reference/cli/task_pause.md" + }, { "title": "task send", "description": "Send input to a task", diff --git a/docs/reference/cli/task.md b/docs/reference/cli/task.md index 9f70c9c4d5022..cb6a1e42c2bb3 100644 --- a/docs/reference/cli/task.md +++ b/docs/reference/cli/task.md @@ -21,5 +21,6 @@ coder task | [delete](./task_delete.md) | Delete tasks | | [list](./task_list.md) | List tasks | | [logs](./task_logs.md) | Show a task's logs | +| [pause](./task_pause.md) | Pause a task | | [send](./task_send.md) | Send input to a task | | [status](./task_status.md) | Show the status of a task. | diff --git a/docs/reference/cli/task_pause.md b/docs/reference/cli/task_pause.md new file mode 100644 index 0000000000000..f3e95f9dc43ab --- /dev/null +++ b/docs/reference/cli/task_pause.md @@ -0,0 +1,32 @@ + +# task pause + +Pause a task + +## Usage + +```console +coder task pause [flags] +``` + +## Description + +```console + - Pause a task by name.: + + $ coder task pause my-task + + - Pause a task without confirmation.: + + $ coder task pause my-task --yes +``` + +## Options + +### -y, --yes + +| | | +|------|-------------------| +| Type | bool | + +Bypass confirmation prompts. From 75cc5b068c5496d998c119941d54f4729d7874bb Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 10 Feb 2026 12:10:32 +0000 Subject: [PATCH 03/10] test: replace unit tests with integration tests for task pause --- cli/task_pause_test.go | 287 ++++++++++++----------------------------- 1 file changed, 86 insertions(+), 201 deletions(-) diff --git a/cli/task_pause_test.go b/cli/task_pause_test.go index 49b3298095304..222c5930fa8e3 100644 --- a/cli/task_pause_test.go +++ b/cli/task_pause_test.go @@ -1,18 +1,16 @@ package cli_test import ( - "bytes" - "net/http" - "net/http/httptest" + "strings" "testing" - "github.com/google/uuid" "github.com/stretchr/testify/require" - "golang.org/x/xerrors" "github.com/coder/coder/v2/cli/clitest" - "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/provisioner/echo" + "github.com/coder/coder/v2/provisionersdk/proto" "github.com/coder/coder/v2/pty/ptytest" "github.com/coder/coder/v2/testutil" ) @@ -20,204 +18,91 @@ import ( func TestExpTaskPause(t *testing.T) { t.Parallel() - type testCase struct { - name string - args []string - promptYes bool - promptNo bool - wantErr string - wantPausedMsg bool - buildHandler func() http.HandlerFunc - } + // setup creates an AI task with a completed workspace build, ready + // to be paused. Follows the pattern from TestPauseTask in + // coderd/aitasks_test.go. + setup := func(t *testing.T) (*codersdk.Client, codersdk.Task) { + t.Helper() + ctx := testutil.Context(t, testutil.WaitLong) - const ( - id1 = "11111111-1111-1111-1111-111111111111" - id2 = "22222222-2222-2222-2222-222222222222" - id3 = "33333333-3333-3333-3333-333333333333" - ) - - cases := []testCase{ - { - name: "OK_ByName", - args: []string{"my-task"}, - promptYes: true, - buildHandler: func() http.HandlerFunc { - taskID := uuid.MustParse(id1) - return func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == http.MethodGet && r.URL.Path == "/api/v2/tasks/me/my-task": - httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Task{ - ID: taskID, - Name: "my-task", - OwnerName: "me", - }) - case r.Method == http.MethodPost && r.URL.Path == "/api/experimental/tasks/me/"+id1+"/pause": - httpapi.Write(r.Context(), w, http.StatusAccepted, codersdk.PauseTaskResponse{}) - default: - httpapi.InternalServerError(w, xerrors.New("unwanted path: "+r.Method+" "+r.URL.Path)) - } - } - }, - wantPausedMsg: true, - }, - { - name: "OK_ByUUID", - args: []string{id2}, - promptYes: true, - buildHandler: func() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == http.MethodGet && r.URL.Path == "/api/v2/tasks/me/"+id2: - httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Task{ - ID: uuid.MustParse(id2), - OwnerName: "me", - Name: "uuid-task", - }) - case r.Method == http.MethodPost && r.URL.Path == "/api/experimental/tasks/me/"+id2+"/pause": - httpapi.Write(r.Context(), w, http.StatusAccepted, codersdk.PauseTaskResponse{}) - default: - httpapi.InternalServerError(w, xerrors.New("unwanted path: "+r.Method+" "+r.URL.Path)) - } - } - }, - wantPausedMsg: true, - }, - { - name: "YesFlag", - args: []string{"--yes", "my-task"}, - buildHandler: func() http.HandlerFunc { - taskID := uuid.MustParse(id3) - return func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == http.MethodGet && r.URL.Path == "/api/v2/tasks/me/my-task": - httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Task{ - ID: taskID, - Name: "my-task", - OwnerName: "me", - }) - case r.Method == http.MethodPost && r.URL.Path == "/api/experimental/tasks/me/"+id3+"/pause": - httpapi.Write(r.Context(), w, http.StatusAccepted, codersdk.PauseTaskResponse{}) - default: - httpapi.InternalServerError(w, xerrors.New("unwanted path: "+r.Method+" "+r.URL.Path)) - } - } - }, - wantPausedMsg: true, - }, - { - name: "ResolveError", - args: []string{"doesnotexist"}, - wantErr: "resolve task", - buildHandler: func() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == http.MethodGet && r.URL.Path == "/api/v2/tasks/me/doesnotexist": - httpapi.Write(r.Context(), w, http.StatusNotFound, codersdk.Response{ - Message: "Task not found.", - }) - default: - httpapi.InternalServerError(w, xerrors.New("unwanted path: "+r.Method+" "+r.URL.Path)) - } - } - }, - }, - { - name: "PauseError", - args: []string{"bad-task"}, - promptYes: true, - wantErr: "pause task", - buildHandler: func() http.HandlerFunc { - taskID := uuid.MustParse(id1) - return func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == http.MethodGet && r.URL.Path == "/api/v2/tasks/me/bad-task": - httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Task{ - ID: taskID, - Name: "bad-task", - OwnerName: "me", - }) - case r.Method == http.MethodPost && r.URL.Path == "/api/experimental/tasks/me/"+id1+"/pause": - httpapi.Write(r.Context(), w, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error.", - Detail: "boom", - }) - default: - httpapi.InternalServerError(w, xerrors.New("unwanted path: "+r.Method+" "+r.URL.Path)) - } - } - }, - }, - { - name: "PromptDeclined", - args: []string{"my-task"}, - promptNo: true, - wantErr: "canceled", - buildHandler: func() http.HandlerFunc { - taskID := uuid.MustParse(id1) - return func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == http.MethodGet && r.URL.Path == "/api/v2/tasks/me/my-task": - httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Task{ - ID: taskID, - Name: "my-task", - OwnerName: "me", - }) - default: - httpapi.InternalServerError(w, xerrors.New("unwanted path: "+r.Method+" "+r.URL.Path)) - } - } + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + userClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionApply: echo.ApplyComplete, + ProvisionGraph: []*proto.Response{ + {Type: &proto.Response_Graph{Graph: &proto.GraphComplete{ + HasAiTasks: true, + }}}, }, - }, - } + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + tpl := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitMedium) - - srv := httptest.NewServer(tc.buildHandler()) - t.Cleanup(srv.Close) - - client := codersdk.New(testutil.MustURL(t, srv.URL)) - - args := append([]string{"task", "pause"}, tc.args...) - inv, root := clitest.New(t, args...) - inv = inv.WithContext(ctx) - clitest.SetupConfig(t, client, root) - - var runErr error - var outBuf bytes.Buffer - if tc.promptYes || tc.promptNo { - pty := ptytest.New(t).Attach(inv) - w := clitest.StartWithWaiter(t, inv) - pty.ExpectMatchContext(ctx, "Pause task") - if tc.promptYes { - pty.WriteLine("yes") - } else { - pty.WriteLine("no") - } - if tc.wantPausedMsg { - pty.ExpectMatchContext(ctx, "has been paused") - } - runErr = w.Wait() - } else { - inv.Stdout = &outBuf - inv.Stderr = &outBuf - runErr = inv.Run() - } - - if tc.wantErr != "" { - require.ErrorContains(t, runErr, tc.wantErr) - } else { - require.NoError(t, runErr) - } - - // For non-PTY tests, verify output from the buffer. - if tc.wantPausedMsg && !tc.promptYes && !tc.promptNo { - output := outBuf.String() - require.Contains(t, output, "has been paused") - } + task, err := userClient.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{ + TemplateVersionID: tpl.ActiveVersionID, + Input: "test task for pause", }) + require.NoError(t, err) + require.True(t, task.WorkspaceID.Valid) + + ws, err := userClient.Workspace(ctx, task.WorkspaceID.UUID) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + + return userClient, task } + + t.Run("WithYesFlag", func(t *testing.T) { + t.Parallel() + + client, task := setup(t) + + var stdout strings.Builder + inv, root := clitest.New(t, "task", "pause", task.Name, "--yes") + inv.Stdout = &stdout + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitLong) + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + require.Contains(t, stdout.String(), "has been paused") + }) + + t.Run("PromptConfirm", func(t *testing.T) { + t.Parallel() + + client, task := setup(t) + + inv, root := clitest.New(t, "task", "pause", task.Name) + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitLong) + inv = inv.WithContext(ctx) + pty := ptytest.New(t).Attach(inv) + w := clitest.StartWithWaiter(t, inv) + pty.ExpectMatchContext(ctx, "Pause task") + pty.WriteLine("yes") + pty.ExpectMatchContext(ctx, "has been paused") + require.NoError(t, w.Wait()) + }) + + t.Run("PromptDecline", func(t *testing.T) { + t.Parallel() + + client, task := setup(t) + + inv, root := clitest.New(t, "task", "pause", task.Name) + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitLong) + inv = inv.WithContext(ctx) + pty := ptytest.New(t).Attach(inv) + w := clitest.StartWithWaiter(t, inv) + pty.ExpectMatchContext(ctx, "Pause task") + pty.WriteLine("no") + require.Error(t, w.Wait()) + }) } From a742b6e00ec4440175a5850e5ad65855930e875c Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 10 Feb 2026 12:13:15 +0000 Subject: [PATCH 04/10] style: move ctx declaration to just before first use --- cli/task_pause_test.go | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/cli/task_pause_test.go b/cli/task_pause_test.go index 222c5930fa8e3..35898da3f59ed 100644 --- a/cli/task_pause_test.go +++ b/cli/task_pause_test.go @@ -23,7 +23,6 @@ func TestExpTaskPause(t *testing.T) { // coderd/aitasks_test.go. setup := func(t *testing.T) (*codersdk.Client, codersdk.Task) { t.Helper() - ctx := testutil.Context(t, testutil.WaitLong) client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) owner := coderdtest.CreateFirstUser(t, client) @@ -41,6 +40,7 @@ func TestExpTaskPause(t *testing.T) { coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) tpl := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + ctx := testutil.Context(t, testutil.WaitMedium) task, err := userClient.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{ TemplateVersionID: tpl.ActiveVersionID, Input: "test task for pause", @@ -65,10 +65,15 @@ func TestExpTaskPause(t *testing.T) { inv.Stdout = &stdout clitest.SetupConfig(t, client, root) - ctx := testutil.Context(t, testutil.WaitLong) + ctx := testutil.Context(t, testutil.WaitMedium) err := inv.WithContext(ctx).Run() require.NoError(t, err) require.Contains(t, stdout.String(), "has been paused") + + // Verify the task is actually paused on the server. + updated, err := client.TaskByIdentifier(ctx, task.Name) + require.NoError(t, err) + require.Equal(t, codersdk.TaskStatusPaused, updated.Status) }) t.Run("PromptConfirm", func(t *testing.T) { @@ -79,7 +84,7 @@ func TestExpTaskPause(t *testing.T) { inv, root := clitest.New(t, "task", "pause", task.Name) clitest.SetupConfig(t, client, root) - ctx := testutil.Context(t, testutil.WaitLong) + ctx := testutil.Context(t, testutil.WaitMedium) inv = inv.WithContext(ctx) pty := ptytest.New(t).Attach(inv) w := clitest.StartWithWaiter(t, inv) @@ -87,6 +92,11 @@ func TestExpTaskPause(t *testing.T) { pty.WriteLine("yes") pty.ExpectMatchContext(ctx, "has been paused") require.NoError(t, w.Wait()) + + // Verify the task is actually paused on the server. + updated, err := client.TaskByIdentifier(ctx, task.Name) + require.NoError(t, err) + require.Equal(t, codersdk.TaskStatusPaused, updated.Status) }) t.Run("PromptDecline", func(t *testing.T) { @@ -97,12 +107,17 @@ func TestExpTaskPause(t *testing.T) { inv, root := clitest.New(t, "task", "pause", task.Name) clitest.SetupConfig(t, client, root) - ctx := testutil.Context(t, testutil.WaitLong) + ctx := testutil.Context(t, testutil.WaitMedium) inv = inv.WithContext(ctx) pty := ptytest.New(t).Attach(inv) w := clitest.StartWithWaiter(t, inv) pty.ExpectMatchContext(ctx, "Pause task") pty.WriteLine("no") require.Error(t, w.Wait()) + + // Verify the task was not paused. + updated, err := client.TaskByIdentifier(ctx, task.Name) + require.NoError(t, err) + require.NotEqual(t, codersdk.TaskStatusPaused, updated.Status) }) } From 4150cd5baa561ed1b95c4c3116ecfa1e73fe7f98 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 11 Feb 2026 10:15:24 +0000 Subject: [PATCH 05/10] fix: address review comments on task pause command - Remove punctuation from example descriptions - Remove dead else branch for nil WorkspaceBuild (never nil on success) - Regenerate golden file and CLI docs --- cli/task_pause.go | 17 +++++------------ cli/testdata/coder_task_pause_--help.golden | 4 ++-- docs/reference/cli/task_pause.md | 20 +++++++++++--------- 3 files changed, 18 insertions(+), 23 deletions(-) diff --git a/cli/task_pause.go b/cli/task_pause.go index f075d41201385..17d87fec7560f 100644 --- a/cli/task_pause.go +++ b/cli/task_pause.go @@ -17,11 +17,11 @@ func (r *RootCmd) taskPause() *serpent.Command { Short: "Pause a task", Long: FormatExamples( Example{ - Description: "Pause a task by name.", + Description: "Pause a task by name", Command: "coder task pause my-task", }, Example{ - Description: "Pause a task without confirmation.", + Description: "Pause a task without confirmation", Command: "coder task pause my-task --yes", }, ), @@ -59,16 +59,9 @@ func (r *RootCmd) taskPause() *serpent.Command { return xerrors.Errorf("pause task %q: %w", display, err) } - if resp.WorkspaceBuild != nil { - err = cliui.WorkspaceBuild(ctx, inv.Stdout, client, resp.WorkspaceBuild.ID) - if err != nil { - return xerrors.Errorf("watch pause build for task %q: %w", display, err) - } - } else { - cliui.Warn(inv.Stderr, - "Pause was accepted but no workspace build was returned.", - "The task may not be fully paused yet.", - ) + err = cliui.WorkspaceBuild(ctx, inv.Stdout, client, resp.WorkspaceBuild.ID) + if err != nil { + return xerrors.Errorf("watch pause build for task %q: %w", display, err) } _, _ = fmt.Fprintf( diff --git a/cli/testdata/coder_task_pause_--help.golden b/cli/testdata/coder_task_pause_--help.golden index 5fb1f10645a99..9814182553a1e 100644 --- a/cli/testdata/coder_task_pause_--help.golden +++ b/cli/testdata/coder_task_pause_--help.golden @@ -5,11 +5,11 @@ USAGE: Pause a task - - Pause a task by name.: + - Pause a task by name: $ coder task pause my-task - - Pause a task without confirmation.: + - Pause a task without confirmation: $ coder task pause my-task --yes diff --git a/docs/reference/cli/task_pause.md b/docs/reference/cli/task_pause.md index f3e95f9dc43ab..5640a82d5e973 100644 --- a/docs/reference/cli/task_pause.md +++ b/docs/reference/cli/task_pause.md @@ -1,32 +1,34 @@ # task pause + Pause a task -## Usage + + +## Usage ```console coder task pause [flags] ``` ## Description - ```console - - Pause a task by name.: + - Pause a task by name: $ coder task pause my-task - - Pause a task without confirmation.: + - Pause a task without confirmation: $ coder task pause my-task --yes ``` -## Options +## Options ### -y, --yes - -| | | -|------|-------------------| + +| | | +| --- | --- | | Type | bool | -Bypass confirmation prompts. +Bypass confirmation prompts. \ No newline at end of file From 128548f59a70bb6a1673deac30224dc31664f945 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 11 Feb 2026 10:26:52 +0000 Subject: [PATCH 06/10] chore: format generated docs --- docs/reference/cli/task_pause.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reference/cli/task_pause.md b/docs/reference/cli/task_pause.md index 5640a82d5e973..c594629e54ff2 100644 --- a/docs/reference/cli/task_pause.md +++ b/docs/reference/cli/task_pause.md @@ -27,8 +27,8 @@ coder task pause [flags] ## Options ### -y, --yes -| | | -| --- | --- | +| | | +|------|-------------------| | Type | bool | Bypass confirmation prompts. \ No newline at end of file From f21f55255f9795a4df0389df08a4a9e43fb0f153 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 11 Feb 2026 10:35:47 +0000 Subject: [PATCH 07/10] test: add cross-user task pause test and CLI example Add a test case (OtherUserTask) that verifies an admin can pause a task owned by another user using the "owner/name" identifier format. Add a corresponding CLI example demonstrating this usage. --- cli/task_pause.go | 4 +++ cli/task_pause_test.go | 39 +++++++++++++++++---- cli/testdata/coder_task_pause_--help.golden | 4 +++ docs/reference/cli/task_pause.md | 16 +++++---- 4 files changed, 50 insertions(+), 13 deletions(-) diff --git a/cli/task_pause.go b/cli/task_pause.go index 17d87fec7560f..8d5a142b8a2a0 100644 --- a/cli/task_pause.go +++ b/cli/task_pause.go @@ -20,6 +20,10 @@ func (r *RootCmd) taskPause() *serpent.Command { Description: "Pause a task by name", Command: "coder task pause my-task", }, + Example{ + Description: "Pause another user's task", + Command: "coder task pause alice/my-task", + }, Example{ Description: "Pause a task without confirmation", Command: "coder task pause my-task --yes", diff --git a/cli/task_pause_test.go b/cli/task_pause_test.go index 35898da3f59ed..aab7577862cda 100644 --- a/cli/task_pause_test.go +++ b/cli/task_pause_test.go @@ -1,6 +1,7 @@ package cli_test import ( + "fmt" "strings" "testing" @@ -20,8 +21,9 @@ func TestExpTaskPause(t *testing.T) { // setup creates an AI task with a completed workspace build, ready // to be paused. Follows the pattern from TestPauseTask in - // coderd/aitasks_test.go. - setup := func(t *testing.T) (*codersdk.Client, codersdk.Task) { + // coderd/aitasks_test.go. Returns the admin client, the member + // client that owns the task, and the task itself. + setup := func(t *testing.T) (adminClient *codersdk.Client, memberClient *codersdk.Client, task codersdk.Task) { t.Helper() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) @@ -52,13 +54,13 @@ func TestExpTaskPause(t *testing.T) { require.NoError(t, err) coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) - return userClient, task + return client, userClient, task } t.Run("WithYesFlag", func(t *testing.T) { t.Parallel() - client, task := setup(t) + _, client, task := setup(t) var stdout strings.Builder inv, root := clitest.New(t, "task", "pause", task.Name, "--yes") @@ -76,10 +78,35 @@ func TestExpTaskPause(t *testing.T) { require.Equal(t, codersdk.TaskStatusPaused, updated.Status) }) + // OtherUserTask verifies that an admin can pause a task owned by + // another user using the "owner/name" identifier format. + t.Run("OtherUserTask", func(t *testing.T) { + t.Parallel() + + adminClient, _, task := setup(t) + + identifier := fmt.Sprintf("%s/%s", task.OwnerName, task.Name) + + var stdout strings.Builder + inv, root := clitest.New(t, "task", "pause", identifier, "--yes") + inv.Stdout = &stdout + clitest.SetupConfig(t, adminClient, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + require.Contains(t, stdout.String(), "has been paused") + + // Verify the task is actually paused on the server. + updated, err := adminClient.TaskByIdentifier(ctx, identifier) + require.NoError(t, err) + require.Equal(t, codersdk.TaskStatusPaused, updated.Status) + }) + t.Run("PromptConfirm", func(t *testing.T) { t.Parallel() - client, task := setup(t) + _, client, task := setup(t) inv, root := clitest.New(t, "task", "pause", task.Name) clitest.SetupConfig(t, client, root) @@ -102,7 +129,7 @@ func TestExpTaskPause(t *testing.T) { t.Run("PromptDecline", func(t *testing.T) { t.Parallel() - client, task := setup(t) + _, client, task := setup(t) inv, root := clitest.New(t, "task", "pause", task.Name) clitest.SetupConfig(t, client, root) diff --git a/cli/testdata/coder_task_pause_--help.golden b/cli/testdata/coder_task_pause_--help.golden index 9814182553a1e..e6c6f5670333c 100644 --- a/cli/testdata/coder_task_pause_--help.golden +++ b/cli/testdata/coder_task_pause_--help.golden @@ -9,6 +9,10 @@ USAGE: $ coder task pause my-task + - Pause another user's task: + + $ coder task pause alice/my-task + - Pause a task without confirmation: $ coder task pause my-task --yes diff --git a/docs/reference/cli/task_pause.md b/docs/reference/cli/task_pause.md index c594629e54ff2..34c14199e10f7 100644 --- a/docs/reference/cli/task_pause.md +++ b/docs/reference/cli/task_pause.md @@ -1,34 +1,36 @@ # task pause - Pause a task - - - ## Usage + ```console coder task pause [flags] ``` ## Description + ```console - Pause a task by name: $ coder task pause my-task + - Pause another user's task: + + $ coder task pause alice/my-task + - Pause a task without confirmation: $ coder task pause my-task --yes ``` - ## Options + ### -y, --yes - + | | | |------|-------------------| | Type | bool | -Bypass confirmation prompts. \ No newline at end of file +Bypass confirmation prompts. From a6796dd26cfdc829dfab7c440e7f01690ddc3d50 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 11 Feb 2026 20:38:40 +0000 Subject: [PATCH 08/10] chore: respond to feedback --- cli/task_logs_test.go | 15 +++------ cli/task_pause.go | 5 +++ cli/task_pause_test.go | 72 ++++++++++-------------------------------- cli/task_send_test.go | 11 +++---- cli/task_test.go | 20 ++++++------ 5 files changed, 40 insertions(+), 83 deletions(-) diff --git a/cli/task_logs_test.go b/cli/task_logs_test.go index 4c01aed7a7f7a..fefbd70bf4266 100644 --- a/cli/task_logs_test.go +++ b/cli/task_logs_test.go @@ -41,8 +41,7 @@ func Test_TaskLogs_Golden(t *testing.T) { t.Parallel() setupCtx := testutil.Context(t, testutil.WaitLong) - client, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages)) - userClient := client // user already has access to their own workspace + _, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages)) inv, root := clitest.New(t, "task", "logs", task.Name, "--output", "json") output := clitest.Capture(inv) @@ -65,8 +64,7 @@ func Test_TaskLogs_Golden(t *testing.T) { t.Parallel() setupCtx := testutil.Context(t, testutil.WaitLong) - client, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages)) - userClient := client + _, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages)) inv, root := clitest.New(t, "task", "logs", task.ID.String(), "--output", "json") output := clitest.Capture(inv) @@ -89,8 +87,7 @@ func Test_TaskLogs_Golden(t *testing.T) { t.Parallel() setupCtx := testutil.Context(t, testutil.WaitLong) - client, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages)) - userClient := client + _, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages)) inv, root := clitest.New(t, "task", "logs", task.ID.String()) output := clitest.Capture(inv) @@ -144,8 +141,7 @@ func Test_TaskLogs_Golden(t *testing.T) { t.Parallel() setupCtx := testutil.Context(t, testutil.WaitLong) - client, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsErr(assert.AnError)) - userClient := client + _, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsErr(assert.AnError)) inv, root := clitest.New(t, "task", "logs", task.ID.String()) clitest.SetupConfig(t, userClient, root) @@ -201,8 +197,7 @@ func Test_TaskLogs_Golden(t *testing.T) { t.Run("SnapshotWithoutLogs_NoSnapshotCaptured", func(t *testing.T) { t.Parallel() - client, task := setupCLITaskTestWithoutSnapshot(t, codersdk.TaskStatusPaused) - userClient := client + userClient, task := setupCLITaskTestWithoutSnapshot(t, codersdk.TaskStatusPaused) inv, root := clitest.New(t, "task", "logs", task.Name) output := clitest.Capture(inv) diff --git a/cli/task_pause.go b/cli/task_pause.go index 8d5a142b8a2a0..669dbc516aa79 100644 --- a/cli/task_pause.go +++ b/cli/task_pause.go @@ -7,6 +7,7 @@ import ( "golang.org/x/xerrors" "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" "github.com/coder/pretty" "github.com/coder/serpent" ) @@ -49,6 +50,10 @@ func (r *RootCmd) taskPause() *serpent.Command { display := fmt.Sprintf("%s/%s", task.OwnerName, task.Name) + if task.Status == codersdk.TaskStatusPaused { + return xerrors.Errorf("task %q is already paused", display) + } + _, err = cliui.Prompt(inv, cliui.PromptOptions{ Text: fmt.Sprintf("Pause task %s?", pretty.Sprint(cliui.DefaultStyles.Code, display)), IsConfirm: true, diff --git a/cli/task_pause_test.go b/cli/task_pause_test.go index aab7577862cda..6d750b2d04194 100644 --- a/cli/task_pause_test.go +++ b/cli/task_pause_test.go @@ -2,16 +2,12 @@ package cli_test import ( "fmt" - "strings" "testing" "github.com/stretchr/testify/require" "github.com/coder/coder/v2/cli/clitest" - "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/codersdk" - "github.com/coder/coder/v2/provisioner/echo" - "github.com/coder/coder/v2/provisionersdk/proto" "github.com/coder/coder/v2/pty/ptytest" "github.com/coder/coder/v2/testutil" ) @@ -19,58 +15,20 @@ import ( func TestExpTaskPause(t *testing.T) { t.Parallel() - // setup creates an AI task with a completed workspace build, ready - // to be paused. Follows the pattern from TestPauseTask in - // coderd/aitasks_test.go. Returns the admin client, the member - // client that owns the task, and the task itself. - setup := func(t *testing.T) (adminClient *codersdk.Client, memberClient *codersdk.Client, task codersdk.Task) { - t.Helper() - - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - owner := coderdtest.CreateFirstUser(t, client) - userClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) - - version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionApply: echo.ApplyComplete, - ProvisionGraph: []*proto.Response{ - {Type: &proto.Response_Graph{Graph: &proto.GraphComplete{ - HasAiTasks: true, - }}}, - }, - }) - coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - tpl := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) - - ctx := testutil.Context(t, testutil.WaitMedium) - task, err := userClient.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{ - TemplateVersionID: tpl.ActiveVersionID, - Input: "test task for pause", - }) - require.NoError(t, err) - require.True(t, task.WorkspaceID.Valid) - - ws, err := userClient.Workspace(ctx, task.WorkspaceID.UUID) - require.NoError(t, err) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) - - return client, userClient, task - } - t.Run("WithYesFlag", func(t *testing.T) { t.Parallel() - _, client, task := setup(t) + setupCtx := testutil.Context(t, testutil.WaitLong) + _, client, task := setupCLITaskTest(setupCtx, t, nil) - var stdout strings.Builder inv, root := clitest.New(t, "task", "pause", task.Name, "--yes") - inv.Stdout = &stdout + output := clitest.Capture(inv) clitest.SetupConfig(t, client, root) ctx := testutil.Context(t, testutil.WaitMedium) err := inv.WithContext(ctx).Run() require.NoError(t, err) - require.Contains(t, stdout.String(), "has been paused") + require.Contains(t, output.Stdout(), "has been paused") // Verify the task is actually paused on the server. updated, err := client.TaskByIdentifier(ctx, task.Name) @@ -83,19 +41,19 @@ func TestExpTaskPause(t *testing.T) { t.Run("OtherUserTask", func(t *testing.T) { t.Parallel() - adminClient, _, task := setup(t) + setupCtx := testutil.Context(t, testutil.WaitLong) + adminClient, _, task := setupCLITaskTest(setupCtx, t, nil) identifier := fmt.Sprintf("%s/%s", task.OwnerName, task.Name) - var stdout strings.Builder inv, root := clitest.New(t, "task", "pause", identifier, "--yes") - inv.Stdout = &stdout + output := clitest.Capture(inv) clitest.SetupConfig(t, adminClient, root) ctx := testutil.Context(t, testutil.WaitMedium) err := inv.WithContext(ctx).Run() require.NoError(t, err) - require.Contains(t, stdout.String(), "has been paused") + require.Contains(t, output.Stdout(), "has been paused") // Verify the task is actually paused on the server. updated, err := adminClient.TaskByIdentifier(ctx, identifier) @@ -106,10 +64,11 @@ func TestExpTaskPause(t *testing.T) { t.Run("PromptConfirm", func(t *testing.T) { t.Parallel() - _, client, task := setup(t) + setupCtx := testutil.Context(t, testutil.WaitLong) + _, userClient, task := setupCLITaskTest(setupCtx, t, nil) inv, root := clitest.New(t, "task", "pause", task.Name) - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, userClient, root) ctx := testutil.Context(t, testutil.WaitMedium) inv = inv.WithContext(ctx) @@ -121,7 +80,7 @@ func TestExpTaskPause(t *testing.T) { require.NoError(t, w.Wait()) // Verify the task is actually paused on the server. - updated, err := client.TaskByIdentifier(ctx, task.Name) + updated, err := userClient.TaskByIdentifier(ctx, task.Name) require.NoError(t, err) require.Equal(t, codersdk.TaskStatusPaused, updated.Status) }) @@ -129,10 +88,11 @@ func TestExpTaskPause(t *testing.T) { t.Run("PromptDecline", func(t *testing.T) { t.Parallel() - _, client, task := setup(t) + setupCtx := testutil.Context(t, testutil.WaitLong) + _, userClient, task := setupCLITaskTest(setupCtx, t, nil) inv, root := clitest.New(t, "task", "pause", task.Name) - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, userClient, root) ctx := testutil.Context(t, testutil.WaitMedium) inv = inv.WithContext(ctx) @@ -143,7 +103,7 @@ func TestExpTaskPause(t *testing.T) { require.Error(t, w.Wait()) // Verify the task was not paused. - updated, err := client.TaskByIdentifier(ctx, task.Name) + updated, err := userClient.TaskByIdentifier(ctx, task.Name) require.NoError(t, err) require.NotEqual(t, codersdk.TaskStatusPaused, updated.Status) }) diff --git a/cli/task_send_test.go b/cli/task_send_test.go index 1648244a020ad..0aa6713404fb0 100644 --- a/cli/task_send_test.go +++ b/cli/task_send_test.go @@ -25,8 +25,7 @@ func Test_TaskSend(t *testing.T) { t.Parallel() setupCtx := testutil.Context(t, testutil.WaitLong) - client, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it")) - userClient := client + _, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it")) var stdout strings.Builder inv, root := clitest.New(t, "task", "send", task.Name, "carry on with the task") @@ -42,8 +41,7 @@ func Test_TaskSend(t *testing.T) { t.Parallel() setupCtx := testutil.Context(t, testutil.WaitLong) - client, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it")) - userClient := client + _, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it")) var stdout strings.Builder inv, root := clitest.New(t, "task", "send", task.ID.String(), "carry on with the task") @@ -59,8 +57,7 @@ func Test_TaskSend(t *testing.T) { t.Parallel() setupCtx := testutil.Context(t, testutil.WaitLong) - client, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it")) - userClient := client + _, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it")) var stdout strings.Builder inv, root := clitest.New(t, "task", "send", task.Name, "--stdin") @@ -113,7 +110,7 @@ func Test_TaskSend(t *testing.T) { t.Parallel() setupCtx := testutil.Context(t, testutil.WaitLong) - userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendErr(t, assert.AnError)) + _, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendErr(t, assert.AnError)) var stdout strings.Builder inv, root := clitest.New(t, "task", "send", task.Name, "some task input") diff --git a/cli/task_test.go b/cli/task_test.go index 6347595fc1fd9..b4d117b2a3a14 100644 --- a/cli/task_test.go +++ b/cli/task_test.go @@ -255,17 +255,17 @@ func fakeAgentAPIEcho(ctx context.Context, t testing.TB, initMsg agentapisdk.Mes // setupCLITaskTest creates a test workspace with an AI task template and agent, // with a fake agent API configured with the provided set of handlers. // Returns the user client and workspace. -func setupCLITaskTest(ctx context.Context, t *testing.T, agentAPIHandlers map[string]http.HandlerFunc) (*codersdk.Client, codersdk.Task) { +func setupCLITaskTest(ctx context.Context, t *testing.T, agentAPIHandlers map[string]http.HandlerFunc) (ownerClient *codersdk.Client, memberClient *codersdk.Client, task codersdk.Task) { t.Helper() - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - owner := coderdtest.CreateFirstUser(t, client) - userClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + ownerClient = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, ownerClient) + userClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) fakeAPI := startFakeAgentAPI(t, agentAPIHandlers) authToken := uuid.NewString() - template := createAITaskTemplate(t, client, owner.OrganizationID, withSidebarURL(fakeAPI.URL()), withAgentToken(authToken)) + template := createAITaskTemplate(t, ownerClient, owner.OrganizationID, withSidebarURL(fakeAPI.URL()), withAgentToken(authToken)) wantPrompt := "test prompt" task, err := userClient.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{ @@ -279,17 +279,17 @@ func setupCLITaskTest(ctx context.Context, t *testing.T, agentAPIHandlers map[st require.True(t, task.WorkspaceID.Valid, "task should have a workspace ID") workspace, err := userClient.Workspace(ctx, task.WorkspaceID.UUID) require.NoError(t, err) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, workspace.LatestBuild.ID) - agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(authToken)) - _ = agenttest.New(t, client.URL, authToken, func(o *agent.Options) { + agentClient := agentsdk.New(userClient.URL, agentsdk.WithFixedToken(authToken)) + _ = agenttest.New(t, userClient.URL, authToken, func(o *agent.Options) { o.Client = agentClient }) - coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID). + coderdtest.NewWorkspaceAgentWaiter(t, userClient, workspace.ID). WaitFor(coderdtest.AgentsReady) - return userClient, task + return ownerClient, userClient, task } // setupCLITaskTestWithSnapshot creates a task in the specified status with a log snapshot. From 4ae8df3a5f9ef5c0a91391beae04df9d1ee56714 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 12 Feb 2026 10:48:43 +0000 Subject: [PATCH 09/10] chore: add an already paused task test --- cli/task_pause_test.go | 50 +++++++++++++++++++++++++++++++++++------- 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/cli/task_pause_test.go b/cli/task_pause_test.go index 6d750b2d04194..761590a859db5 100644 --- a/cli/task_pause_test.go +++ b/cli/task_pause_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/require" "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/pty/ptytest" "github.com/coder/coder/v2/testutil" @@ -18,20 +19,22 @@ func TestExpTaskPause(t *testing.T) { t.Run("WithYesFlag", func(t *testing.T) { t.Parallel() + // Given: A running task setupCtx := testutil.Context(t, testutil.WaitLong) - _, client, task := setupCLITaskTest(setupCtx, t, nil) + _, userClient, task := setupCLITaskTest(setupCtx, t, nil) + // When: We attempt to pause the task inv, root := clitest.New(t, "task", "pause", task.Name, "--yes") output := clitest.Capture(inv) - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, userClient, root) + // Then: Expect the task to be paused ctx := testutil.Context(t, testutil.WaitMedium) err := inv.WithContext(ctx).Run() require.NoError(t, err) require.Contains(t, output.Stdout(), "has been paused") - // Verify the task is actually paused on the server. - updated, err := client.TaskByIdentifier(ctx, task.Name) + updated, err := userClient.TaskByIdentifier(ctx, task.Name) require.NoError(t, err) require.Equal(t, codersdk.TaskStatusPaused, updated.Status) }) @@ -41,21 +44,22 @@ func TestExpTaskPause(t *testing.T) { t.Run("OtherUserTask", func(t *testing.T) { t.Parallel() + // Given: A different user's running task setupCtx := testutil.Context(t, testutil.WaitLong) adminClient, _, task := setupCLITaskTest(setupCtx, t, nil) + // When: We attempt to pause their task identifier := fmt.Sprintf("%s/%s", task.OwnerName, task.Name) - inv, root := clitest.New(t, "task", "pause", identifier, "--yes") output := clitest.Capture(inv) clitest.SetupConfig(t, adminClient, root) + // Then: We expect the task to be paused ctx := testutil.Context(t, testutil.WaitMedium) err := inv.WithContext(ctx).Run() require.NoError(t, err) require.Contains(t, output.Stdout(), "has been paused") - // Verify the task is actually paused on the server. updated, err := adminClient.TaskByIdentifier(ctx, identifier) require.NoError(t, err) require.Equal(t, codersdk.TaskStatusPaused, updated.Status) @@ -64,22 +68,26 @@ func TestExpTaskPause(t *testing.T) { t.Run("PromptConfirm", func(t *testing.T) { t.Parallel() + // Given: A running task setupCtx := testutil.Context(t, testutil.WaitLong) _, userClient, task := setupCLITaskTest(setupCtx, t, nil) + // When: We attempt to pause the task inv, root := clitest.New(t, "task", "pause", task.Name) clitest.SetupConfig(t, userClient, root) + // And: We confirm we want to pause the task ctx := testutil.Context(t, testutil.WaitMedium) inv = inv.WithContext(ctx) pty := ptytest.New(t).Attach(inv) w := clitest.StartWithWaiter(t, inv) pty.ExpectMatchContext(ctx, "Pause task") pty.WriteLine("yes") + + // Then: We expect the task to be paused pty.ExpectMatchContext(ctx, "has been paused") require.NoError(t, w.Wait()) - // Verify the task is actually paused on the server. updated, err := userClient.TaskByIdentifier(ctx, task.Name) require.NoError(t, err) require.Equal(t, codersdk.TaskStatusPaused, updated.Status) @@ -88,12 +96,15 @@ func TestExpTaskPause(t *testing.T) { t.Run("PromptDecline", func(t *testing.T) { t.Parallel() + // Given: A running task setupCtx := testutil.Context(t, testutil.WaitLong) _, userClient, task := setupCLITaskTest(setupCtx, t, nil) + // When: We attempt to pause the task inv, root := clitest.New(t, "task", "pause", task.Name) clitest.SetupConfig(t, userClient, root) + // But: We say no at the confirmation screen ctx := testutil.Context(t, testutil.WaitMedium) inv = inv.WithContext(ctx) pty := ptytest.New(t).Attach(inv) @@ -102,9 +113,32 @@ func TestExpTaskPause(t *testing.T) { pty.WriteLine("no") require.Error(t, w.Wait()) - // Verify the task was not paused. + // Then: We expect the task to not be paused updated, err := userClient.TaskByIdentifier(ctx, task.Name) require.NoError(t, err) require.NotEqual(t, codersdk.TaskStatusPaused, updated.Status) }) + + t.Run("TaskAlreadyPaused", func(t *testing.T) { + t.Parallel() + + // Given: A running task + setupCtx := testutil.Context(t, testutil.WaitLong) + _, userClient, task := setupCLITaskTest(setupCtx, t, nil) + + // And: We paused the running task + ctx := testutil.Context(t, testutil.WaitMedium) + resp, err := userClient.PauseTask(ctx, task.OwnerName, task.ID) + require.NoError(t, err) + require.NotNil(t, resp.WorkspaceBuild) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, resp.WorkspaceBuild.ID) + + // When: We attempt to pause the task again + inv, root := clitest.New(t, "task", "pause", task.Name, "--yes") + clitest.SetupConfig(t, userClient, root) + + // Then: We expect to get an error that the task is already paused + err = inv.WithContext(ctx).Run() + require.ErrorContains(t, err, "is already paused") + }) } From 6a2918c2c6acd67e1f2bb0de8e74586fb264ae0c Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 12 Feb 2026 16:08:38 +0000 Subject: [PATCH 10/10] chore: return error when `WorkspaceBuild == nil` --- cli/task_pause.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cli/task_pause.go b/cli/task_pause.go index 669dbc516aa79..cae2cba6be815 100644 --- a/cli/task_pause.go +++ b/cli/task_pause.go @@ -68,6 +68,10 @@ func (r *RootCmd) taskPause() *serpent.Command { return xerrors.Errorf("pause task %q: %w", display, err) } + if resp.WorkspaceBuild == nil { + return xerrors.Errorf("pause task %q: no workspace build returned", display) + } + err = cliui.WorkspaceBuild(ctx, inv.Stdout, client, resp.WorkspaceBuild.ID) if err != nil { return xerrors.Errorf("watch pause build for task %q: %w", display, err)