feat(cli): add coder exp tasks list (#19496)

Fixes coder/internal#892
Fixes coder/internal#896

Example output:

```
❯ coder exp task list
ID                                    NAME                         STATUS   STATE  STATE CHANGED  MESSAGE
a7a27450-ca16-4553-a6c5-9d6f04808569  task-hardcore-herschel-bd08  running  idle   5h22m3s ago    Listed root directory contents, working directory reset
50f92138-f463-4f2b-abad-1816264b065f  task-musing-dewdney-f058     running  idle   6h3m8s ago     Completed arithmetic calculation
```
This commit is contained in:
Mathias Fredriksson
2025-08-25 13:03:32 +00:00
committed by GitHub
parent cef2904108
commit 836324e641
7 changed files with 474 additions and 23 deletions
+1
View File
@@ -16,6 +16,7 @@ func (r *RootCmd) expCmd() *serpent.Command {
r.mcpCommand(),
r.promptExample(),
r.rptyCommand(),
r.tasksCommand(),
},
}
return cmd
+20
View File
@@ -0,0 +1,20 @@
package cli
import (
"github.com/coder/serpent"
)
func (r *RootCmd) tasksCommand() *serpent.Command {
cmd := &serpent.Command{
Use: "task",
Aliases: []string{"tasks"},
Short: "Experimental task commands.",
Handler: func(i *serpent.Invocation) error {
return i.Command.HelpHandler(i)
},
Children: []*serpent.Command{
r.taskList(),
},
}
return cmd
}
+142
View File
@@ -0,0 +1,142 @@
package cli
import (
"fmt"
"strings"
"time"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
type taskListRow struct {
Task codersdk.Task `table:"t,recursive_inline"`
StateChangedAgo string `table:"state changed"`
}
func taskListRowFromTask(now time.Time, t codersdk.Task) taskListRow {
var stateAgo string
if t.CurrentState != nil {
stateAgo = now.UTC().Sub(t.CurrentState.Timestamp).Truncate(time.Second).String() + " ago"
}
return taskListRow{
Task: t,
StateChangedAgo: stateAgo,
}
}
func (r *RootCmd) taskList() *serpent.Command {
var (
statusFilter string
all bool
user string
client = new(codersdk.Client)
formatter = cliui.NewOutputFormatter(
cliui.TableFormat(
[]taskListRow{},
[]string{
"id",
"name",
"status",
"state",
"state changed",
"message",
},
),
cliui.ChangeFormatterData(
cliui.JSONFormat(),
func(data any) (any, error) {
rows, ok := data.([]taskListRow)
if !ok {
return nil, xerrors.Errorf("expected []taskListRow, got %T", data)
}
out := make([]codersdk.Task, len(rows))
for i := range rows {
out[i] = rows[i].Task
}
return out, nil
},
),
)
)
cmd := &serpent.Command{
Use: "list",
Short: "List experimental tasks",
Aliases: []string{"ls"},
Middleware: serpent.Chain(
serpent.RequireNArgs(0),
r.InitClient(client),
),
Options: serpent.OptionSet{
{
Name: "status",
Description: "Filter by task status (e.g. running, failed, etc).",
Flag: "status",
Default: "",
Value: serpent.StringOf(&statusFilter),
},
{
Name: "all",
Description: "List tasks for all users you can view.",
Flag: "all",
FlagShorthand: "a",
Default: "false",
Value: serpent.BoolOf(&all),
},
{
Name: "user",
Description: "List tasks for the specified user (username, \"me\").",
Flag: "user",
Default: "",
Value: serpent.StringOf(&user),
},
},
Handler: func(inv *serpent.Invocation) error {
ctx := inv.Context()
exp := codersdk.NewExperimentalClient(client)
targetUser := strings.TrimSpace(user)
if targetUser == "" && !all {
targetUser = codersdk.Me
}
tasks, err := exp.Tasks(ctx, &codersdk.TasksFilter{
Owner: targetUser,
Status: statusFilter,
})
if err != nil {
return xerrors.Errorf("list tasks: %w", err)
}
// If no rows and not JSON, show a friendly message.
if len(tasks) == 0 && formatter.FormatID() != cliui.JSONFormat().ID() {
_, _ = fmt.Fprintln(inv.Stderr, "No tasks found.")
return nil
}
rows := make([]taskListRow, len(tasks))
now := time.Now()
for i := range tasks {
rows[i] = taskListRowFromTask(now, tasks[i])
}
out, err := formatter.Format(ctx, rows)
if err != nil {
return xerrors.Errorf("format tasks: %w", err)
}
_, _ = fmt.Fprintln(inv.Stdout, out)
return nil
},
}
formatter.AttachOptions(&cmd.Options)
return cmd
}
+278
View File
@@ -0,0 +1,278 @@
package cli_test
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"io"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
)
// makeAITask creates an AI-task workspace.
func makeAITask(t *testing.T, db database.Store, orgID, adminID, ownerID uuid.UUID, transition database.WorkspaceTransition, prompt string) (workspace database.WorkspaceTable) {
t.Helper()
tv := dbfake.TemplateVersion(t, db).
Seed(database.TemplateVersion{
OrganizationID: orgID,
CreatedBy: adminID,
HasAITask: sql.NullBool{
Bool: true,
Valid: true,
},
}).Do()
ws := database.WorkspaceTable{
OrganizationID: orgID,
OwnerID: ownerID,
TemplateID: tv.Template.ID,
}
build := dbfake.WorkspaceBuild(t, db, ws).
Seed(database.WorkspaceBuild{
TemplateVersionID: tv.TemplateVersion.ID,
Transition: transition,
}).WithAgent().Do()
dbgen.WorkspaceBuildParameters(t, db, []database.WorkspaceBuildParameter{
{
WorkspaceBuildID: build.Build.ID,
Name: codersdk.AITaskPromptParameterName,
Value: prompt,
},
})
agents, err := db.GetWorkspaceAgentsByWorkspaceAndBuildNumber(
dbauthz.AsSystemRestricted(context.Background()),
database.GetWorkspaceAgentsByWorkspaceAndBuildNumberParams{
WorkspaceID: build.Workspace.ID,
BuildNumber: build.Build.BuildNumber,
},
)
require.NoError(t, err)
require.NotEmpty(t, agents)
agentID := agents[0].ID
// Create a workspace app and set it as the sidebar app.
app := dbgen.WorkspaceApp(t, db, database.WorkspaceApp{
AgentID: agentID,
Slug: "task-sidebar",
DisplayName: "Task Sidebar",
External: false,
})
// Update build flags to reference the sidebar app and HasAITask=true.
err = db.UpdateWorkspaceBuildFlagsByID(
dbauthz.AsSystemRestricted(context.Background()),
database.UpdateWorkspaceBuildFlagsByIDParams{
ID: build.Build.ID,
HasAITask: sql.NullBool{Bool: true, Valid: true},
HasExternalAgent: sql.NullBool{Bool: false, Valid: false},
SidebarAppID: uuid.NullUUID{UUID: app.ID, Valid: true},
UpdatedAt: build.Build.UpdatedAt,
},
)
require.NoError(t, err)
return build.Workspace
}
func TestExpTaskList(t *testing.T) {
t.Parallel()
t.Run("NoTasks_Table", func(t *testing.T) {
t.Parallel()
// Quiet logger to reduce noise.
quiet := slog.Make(sloghuman.Sink(io.Discard))
client, _ := coderdtest.NewWithDatabase(t, &coderdtest.Options{Logger: &quiet})
owner := coderdtest.CreateFirstUser(t, client)
memberClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
inv, root := clitest.New(t, "exp", "task", "list")
clitest.SetupConfig(t, memberClient, root)
pty := ptytest.New(t).Attach(inv)
ctx := testutil.Context(t, testutil.WaitShort)
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
pty.ExpectMatch("No tasks found.")
})
t.Run("Single_Table", func(t *testing.T) {
t.Parallel()
// Quiet logger to reduce noise.
quiet := slog.Make(sloghuman.Sink(io.Discard))
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{Logger: &quiet})
owner := coderdtest.CreateFirstUser(t, client)
memberClient, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
wantPrompt := "build me a web app"
ws := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStart, wantPrompt)
inv, root := clitest.New(t, "exp", "task", "list", "--column", "id,name,status,initial prompt")
clitest.SetupConfig(t, memberClient, root)
pty := ptytest.New(t).Attach(inv)
ctx := testutil.Context(t, testutil.WaitShort)
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
// Validate the table includes the task and status.
pty.ExpectMatch(ws.Name)
pty.ExpectMatch("running")
pty.ExpectMatch(wantPrompt)
})
t.Run("StatusFilter_JSON", func(t *testing.T) {
t.Parallel()
// Quiet logger to reduce noise.
quiet := slog.Make(sloghuman.Sink(io.Discard))
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{Logger: &quiet})
owner := coderdtest.CreateFirstUser(t, client)
memberClient, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
// Create two AI tasks: one running, one stopped.
running := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStart, "keep me running")
stopped := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStop, "stop me please")
// Use JSON output to reliably validate filtering.
inv, root := clitest.New(t, "exp", "task", "list", "--status=stopped", "--output=json")
clitest.SetupConfig(t, memberClient, root)
ctx := testutil.Context(t, testutil.WaitShort)
var stdout bytes.Buffer
inv.Stdout = &stdout
inv.Stderr = &stdout
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
var tasks []codersdk.Task
require.NoError(t, json.Unmarshal(stdout.Bytes(), &tasks))
// Only the stopped task is returned.
require.Len(t, tasks, 1, "expected one task after filtering")
require.Equal(t, stopped.ID, tasks[0].ID)
require.NotEqual(t, running.ID, tasks[0].ID)
})
t.Run("UserFlag_Me_Table", func(t *testing.T) {
t.Parallel()
quiet := slog.Make(sloghuman.Sink(io.Discard))
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{Logger: &quiet})
owner := coderdtest.CreateFirstUser(t, client)
_, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
_ = makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStart, "other-task")
ws := makeAITask(t, db, owner.OrganizationID, owner.UserID, owner.UserID, database.WorkspaceTransitionStart, "me-task")
inv, root := clitest.New(t, "exp", "task", "list", "--user", "me")
//nolint:gocritic // Owner client is intended here smoke test the member task not showing up.
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t).Attach(inv)
ctx := testutil.Context(t, testutil.WaitShort)
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
pty.ExpectMatch(ws.Name)
})
}
func TestExpTaskList_OwnerCanListOthers(t *testing.T) {
t.Parallel()
// Quiet logger to reduce noise.
quiet := slog.Make(sloghuman.Sink(io.Discard))
ownerClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{Logger: &quiet})
owner := coderdtest.CreateFirstUser(t, ownerClient)
// Create two additional members in the owner's organization.
_, memberAUser := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
_, memberBUser := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
// Seed an AI task for member A and B.
_ = makeAITask(t, db, owner.OrganizationID, owner.UserID, memberAUser.ID, database.WorkspaceTransitionStart, "member-A-task")
_ = makeAITask(t, db, owner.OrganizationID, owner.UserID, memberBUser.ID, database.WorkspaceTransitionStart, "member-B-task")
t.Run("OwnerListsSpecificUserWithUserFlag_JSON", func(t *testing.T) {
t.Parallel()
// As the owner, list only member A tasks.
inv, root := clitest.New(t, "exp", "task", "list", "--user", memberAUser.Username, "--output=json")
//nolint:gocritic // Owner client is intended here to allow member tasks to be listed.
clitest.SetupConfig(t, ownerClient, root)
var stdout bytes.Buffer
inv.Stdout = &stdout
ctx := testutil.Context(t, testutil.WaitShort)
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
var tasks []codersdk.Task
require.NoError(t, json.Unmarshal(stdout.Bytes(), &tasks))
// At least one task to belong to member A.
require.NotEmpty(t, tasks, "expected at least one task for member A")
// All tasks should belong to member A.
for _, task := range tasks {
require.Equal(t, memberAUser.ID, task.OwnerID, "expected only member A tasks")
}
})
t.Run("OwnerListsAllWithAllFlag_JSON", func(t *testing.T) {
t.Parallel()
// As the owner, list all tasks to verify both member tasks are present.
// Use JSON output to reliably validate filtering.
inv, root := clitest.New(t, "exp", "task", "list", "--all", "--output=json")
//nolint:gocritic // Owner client is intended here to allow all tasks to be listed.
clitest.SetupConfig(t, ownerClient, root)
var stdout bytes.Buffer
inv.Stdout = &stdout
ctx := testutil.Context(t, testutil.WaitShort)
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
var tasks []codersdk.Task
require.NoError(t, json.Unmarshal(stdout.Bytes(), &tasks))
// Expect at least two tasks and ensure both owners (member A and member B) are represented.
require.GreaterOrEqual(t, len(tasks), 2, "expected two or more tasks in --all listing")
// Use slice.Find for concise existence checks.
_, foundA := slice.Find(tasks, func(t codersdk.Task) bool { return t.OwnerID == memberAUser.ID })
_, foundB := slice.Find(tasks, func(t codersdk.Task) bool { return t.OwnerID == memberBUser.ID })
require.True(t, foundA, "expected at least one task for member A in --all listing")
require.True(t, foundB, "expected at least one task for member B in --all listing")
})
}
+1 -1
View File
@@ -280,7 +280,7 @@ func (api *API) tasksList(rw http.ResponseWriter, r *http.Request) {
// Ensure that we only include AI task workspaces in the results.
filter.HasAITask = sql.NullBool{Valid: true, Bool: true}
if filter.OwnerUsername == "me" || filter.OwnerUsername == "" {
if filter.OwnerUsername == "me" {
filter.OwnerID = apiKey.UserID
filter.OwnerUsername = ""
}
+2 -2
View File
@@ -1008,10 +1008,10 @@ func New(options *Options) *API {
r.Route("/tasks", func(r chi.Router) {
r.Use(apiRateLimiter)
r.Get("/", api.tasksList)
r.Route("/{user}", func(r chi.Router) {
r.Use(httpmw.ExtractOrganizationMembersParam(options.Database, api.HTTPAuth.Authorize))
r.Get("/", api.tasksList)
r.Get("/{id}", api.taskGet)
r.Post("/", api.tasksCreate)
})
+30 -20
View File
@@ -88,35 +88,41 @@ const (
//
// Experimental: This type is experimental and may change in the future.
type Task struct {
ID uuid.UUID `json:"id" format:"uuid"`
OrganizationID uuid.UUID `json:"organization_id" format:"uuid"`
OwnerID uuid.UUID `json:"owner_id" format:"uuid"`
Name string `json:"name"`
TemplateID uuid.UUID `json:"template_id" format:"uuid"`
WorkspaceID uuid.NullUUID `json:"workspace_id" format:"uuid"`
InitialPrompt string `json:"initial_prompt"`
Status WorkspaceStatus `json:"status" enums:"pending,starting,running,stopping,stopped,failed,canceling,canceled,deleting,deleted"`
CurrentState *TaskStateEntry `json:"current_state"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
ID uuid.UUID `json:"id" format:"uuid" table:"id"`
OrganizationID uuid.UUID `json:"organization_id" format:"uuid" table:"organization id"`
OwnerID uuid.UUID `json:"owner_id" format:"uuid" table:"owner id"`
Name string `json:"name" table:"name,default_sort"`
TemplateID uuid.UUID `json:"template_id" format:"uuid" table:"template id"`
WorkspaceID uuid.NullUUID `json:"workspace_id" format:"uuid" table:"workspace id"`
InitialPrompt string `json:"initial_prompt" table:"initial prompt"`
Status WorkspaceStatus `json:"status" enums:"pending,starting,running,stopping,stopped,failed,canceling,canceled,deleting,deleted" table:"status"`
CurrentState *TaskStateEntry `json:"current_state" table:"cs,recursive_inline"`
CreatedAt time.Time `json:"created_at" format:"date-time" table:"created at"`
UpdatedAt time.Time `json:"updated_at" format:"date-time" table:"updated at"`
}
// TaskStateEntry represents a single entry in the task's state history.
//
// Experimental: This type is experimental and may change in the future.
type TaskStateEntry struct {
Timestamp time.Time `json:"timestamp" format:"date-time"`
State TaskState `json:"state" enum:"working,idle,completed,failed"`
Message string `json:"message"`
URI string `json:"uri"`
Timestamp time.Time `json:"timestamp" format:"date-time" table:"-"`
State TaskState `json:"state" enum:"working,idle,completed,failed" table:"state"`
Message string `json:"message" table:"message"`
URI string `json:"uri" table:"-"`
}
// TasksFilter filters the list of tasks.
//
// Experimental: This type is experimental and may change in the future.
type TasksFilter struct {
// Owner can be a username, UUID, or "me"
// Owner can be a username, UUID, or "me".
Owner string `json:"owner,omitempty"`
// Status is a task status.
Status string `json:"status,omitempty" typescript:"-"`
// Offset is the number of tasks to skip before returning results.
Offset int `json:"offset,omitempty" typescript:"-"`
// Limit is a limit on the number of tasks returned.
Limit int `json:"limit,omitempty" typescript:"-"`
}
// Tasks lists all tasks belonging to the user or specified owner.
@@ -126,12 +132,16 @@ func (c *ExperimentalClient) Tasks(ctx context.Context, filter *TasksFilter) ([]
if filter == nil {
filter = &TasksFilter{}
}
user := filter.Owner
if user == "" {
user = "me"
var wsFilter WorkspaceFilter
wsFilter.Owner = filter.Owner
wsFilter.Status = filter.Status
page := Pagination{
Offset: filter.Offset,
Limit: filter.Limit,
}
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/tasks/%s", user), nil)
res, err := c.Request(ctx, http.MethodGet, "/api/experimental/tasks", nil, wsFilter.asRequestOption(), page.asRequestOption())
if err != nil {
return nil, err
}