feat(cli): add coder task pause command (#22012)

Adds a new `coder task pause`
This commit is contained in:
Danielle Maywood
2026-02-13 14:21:31 +00:00
committed by GitHub
parent 01f06671a1
commit 6d41d98b65
11 changed files with 339 additions and 27 deletions
+1
View File
@@ -17,6 +17,7 @@ func (r *RootCmd) tasksCommand() *serpent.Command {
r.taskDelete(), r.taskDelete(),
r.taskList(), r.taskList(),
r.taskLogs(), r.taskLogs(),
r.taskPause(),
r.taskSend(), r.taskSend(),
r.taskStatus(), r.taskStatus(),
}, },
+5 -10
View File
@@ -41,8 +41,7 @@ func Test_TaskLogs_Golden(t *testing.T) {
t.Parallel() t.Parallel()
setupCtx := testutil.Context(t, testutil.WaitLong) setupCtx := testutil.Context(t, testutil.WaitLong)
client, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages)) _, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages))
userClient := client // user already has access to their own workspace
inv, root := clitest.New(t, "task", "logs", task.Name, "--output", "json") inv, root := clitest.New(t, "task", "logs", task.Name, "--output", "json")
output := clitest.Capture(inv) output := clitest.Capture(inv)
@@ -65,8 +64,7 @@ func Test_TaskLogs_Golden(t *testing.T) {
t.Parallel() t.Parallel()
setupCtx := testutil.Context(t, testutil.WaitLong) setupCtx := testutil.Context(t, testutil.WaitLong)
client, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages)) _, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages))
userClient := client
inv, root := clitest.New(t, "task", "logs", task.ID.String(), "--output", "json") inv, root := clitest.New(t, "task", "logs", task.ID.String(), "--output", "json")
output := clitest.Capture(inv) output := clitest.Capture(inv)
@@ -89,8 +87,7 @@ func Test_TaskLogs_Golden(t *testing.T) {
t.Parallel() t.Parallel()
setupCtx := testutil.Context(t, testutil.WaitLong) setupCtx := testutil.Context(t, testutil.WaitLong)
client, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages)) _, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages))
userClient := client
inv, root := clitest.New(t, "task", "logs", task.ID.String()) inv, root := clitest.New(t, "task", "logs", task.ID.String())
output := clitest.Capture(inv) output := clitest.Capture(inv)
@@ -144,8 +141,7 @@ func Test_TaskLogs_Golden(t *testing.T) {
t.Parallel() t.Parallel()
setupCtx := testutil.Context(t, testutil.WaitLong) setupCtx := testutil.Context(t, testutil.WaitLong)
client, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsErr(assert.AnError)) _, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsErr(assert.AnError))
userClient := client
inv, root := clitest.New(t, "task", "logs", task.ID.String()) inv, root := clitest.New(t, "task", "logs", task.ID.String())
clitest.SetupConfig(t, userClient, root) 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.Run("SnapshotWithoutLogs_NoSnapshotCaptured", func(t *testing.T) {
t.Parallel() t.Parallel()
client, task := setupCLITaskTestWithoutSnapshot(t, codersdk.TaskStatusPaused) userClient, task := setupCLITaskTestWithoutSnapshot(t, codersdk.TaskStatusPaused)
userClient := client
inv, root := clitest.New(t, "task", "logs", task.Name) inv, root := clitest.New(t, "task", "logs", task.Name)
output := clitest.Capture(inv) output := clitest.Capture(inv)
+90
View File
@@ -0,0 +1,90 @@
package cli
import (
"fmt"
"time"
"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"
)
func (r *RootCmd) taskPause() *serpent.Command {
cmd := &serpent.Command{
Use: "pause <task>",
Short: "Pause a task",
Long: FormatExamples(
Example{
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",
},
),
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)
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,
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 {
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)
}
_, _ = 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
}
+144
View File
@@ -0,0 +1,144 @@
package cli_test
import (
"fmt"
"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/pty/ptytest"
"github.com/coder/coder/v2/testutil"
)
func TestExpTaskPause(t *testing.T) {
t.Parallel()
t.Run("WithYesFlag", 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, "--yes")
output := clitest.Capture(inv)
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")
updated, err := userClient.TaskByIdentifier(ctx, task.Name)
require.NoError(t, err)
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()
// 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")
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()
// 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())
updated, err := userClient.TaskByIdentifier(ctx, task.Name)
require.NoError(t, err)
require.Equal(t, codersdk.TaskStatusPaused, updated.Status)
})
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)
w := clitest.StartWithWaiter(t, inv)
pty.ExpectMatchContext(ctx, "Pause task")
pty.WriteLine("no")
require.Error(t, w.Wait())
// 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")
})
}
+4 -7
View File
@@ -25,8 +25,7 @@ func Test_TaskSend(t *testing.T) {
t.Parallel() t.Parallel()
setupCtx := testutil.Context(t, testutil.WaitLong) setupCtx := testutil.Context(t, testutil.WaitLong)
client, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it")) _, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it"))
userClient := client
var stdout strings.Builder var stdout strings.Builder
inv, root := clitest.New(t, "task", "send", task.Name, "carry on with the task") 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() t.Parallel()
setupCtx := testutil.Context(t, testutil.WaitLong) setupCtx := testutil.Context(t, testutil.WaitLong)
client, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it")) _, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it"))
userClient := client
var stdout strings.Builder var stdout strings.Builder
inv, root := clitest.New(t, "task", "send", task.ID.String(), "carry on with the task") 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() t.Parallel()
setupCtx := testutil.Context(t, testutil.WaitLong) setupCtx := testutil.Context(t, testutil.WaitLong)
client, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it")) _, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it"))
userClient := client
var stdout strings.Builder var stdout strings.Builder
inv, root := clitest.New(t, "task", "send", task.Name, "--stdin") inv, root := clitest.New(t, "task", "send", task.Name, "--stdin")
@@ -113,7 +110,7 @@ func Test_TaskSend(t *testing.T) {
t.Parallel() t.Parallel()
setupCtx := testutil.Context(t, testutil.WaitLong) 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 var stdout strings.Builder
inv, root := clitest.New(t, "task", "send", task.Name, "some task input") inv, root := clitest.New(t, "task", "send", task.Name, "some task input")
+27 -10
View File
@@ -120,6 +120,23 @@ func Test_Tasks(t *testing.T) {
require.Equal(t, logs[2].Type, codersdk.TaskLogTypeOutput, "third message should be an output") 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", name: "delete task",
cmdArgs: []string{"task", "delete", taskName, "--yes"}, cmdArgs: []string{"task", "delete", taskName, "--yes"},
@@ -238,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, // setupCLITaskTest creates a test workspace with an AI task template and agent,
// with a fake agent API configured with the provided set of handlers. // with a fake agent API configured with the provided set of handlers.
// Returns the user client and workspace. // 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() t.Helper()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) ownerClient = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client) owner := coderdtest.CreateFirstUser(t, ownerClient)
userClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) userClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
fakeAPI := startFakeAgentAPI(t, agentAPIHandlers) fakeAPI := startFakeAgentAPI(t, agentAPIHandlers)
authToken := uuid.NewString() 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" wantPrompt := "test prompt"
task, err := userClient.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{ task, err := userClient.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{
@@ -262,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") require.True(t, task.WorkspaceID.Valid, "task should have a workspace ID")
workspace, err := userClient.Workspace(ctx, task.WorkspaceID.UUID) workspace, err := userClient.Workspace(ctx, task.WorkspaceID.UUID)
require.NoError(t, err) 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)) agentClient := agentsdk.New(userClient.URL, agentsdk.WithFixedToken(authToken))
_ = agenttest.New(t, client.URL, authToken, func(o *agent.Options) { _ = agenttest.New(t, userClient.URL, authToken, func(o *agent.Options) {
o.Client = agentClient o.Client = agentClient
}) })
coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID). coderdtest.NewWorkspaceAgentWaiter(t, userClient, workspace.ID).
WaitFor(coderdtest.AgentsReady) WaitFor(coderdtest.AgentsReady)
return userClient, task return ownerClient, userClient, task
} }
// setupCLITaskTestWithSnapshot creates a task in the specified status with a log snapshot. // setupCLITaskTestWithSnapshot creates a task in the specified status with a log snapshot.
+1
View File
@@ -12,6 +12,7 @@ SUBCOMMANDS:
delete Delete tasks delete Delete tasks
list List tasks list List tasks
logs Show a task's logs logs Show a task's logs
pause Pause a task
send Send input to a task send Send input to a task
status Show the status of a task. status Show the status of a task.
+25
View File
@@ -0,0 +1,25 @@
coder v0.0.0-devel
USAGE:
coder task pause [flags] <task>
Pause a task
- 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 bool
Bypass confirmation prompts.
———
Run `coder --help` for a list of global options.
+5
View File
@@ -2009,6 +2009,11 @@
"description": "Show a task's logs", "description": "Show a task's logs",
"path": "reference/cli/task_logs.md" "path": "reference/cli/task_logs.md"
}, },
{
"title": "task pause",
"description": "Pause a task",
"path": "reference/cli/task_pause.md"
},
{ {
"title": "task send", "title": "task send",
"description": "Send input to a task", "description": "Send input to a task",
+1
View File
@@ -21,5 +21,6 @@ coder task
| [<code>delete</code>](./task_delete.md) | Delete tasks | | [<code>delete</code>](./task_delete.md) | Delete tasks |
| [<code>list</code>](./task_list.md) | List tasks | | [<code>list</code>](./task_list.md) | List tasks |
| [<code>logs</code>](./task_logs.md) | Show a task's logs | | [<code>logs</code>](./task_logs.md) | Show a task's logs |
| [<code>pause</code>](./task_pause.md) | Pause a task |
| [<code>send</code>](./task_send.md) | Send input to a task | | [<code>send</code>](./task_send.md) | Send input to a task |
| [<code>status</code>](./task_status.md) | Show the status of a task. | | [<code>status</code>](./task_status.md) | Show the status of a task. |
+36
View File
@@ -0,0 +1,36 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# task pause
Pause a task
## Usage
```console
coder task pause [flags] <task>
```
## 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 | <code>bool</code> |
Bypass confirmation prompts.