Skip to content

Commit 4310e5d

Browse files
committed
feat(cli): add coder exp tasks list
Fixes #892, #896
1 parent 427b23f commit 4310e5d

File tree

4 files changed

+190
-22
lines changed

4 files changed

+190
-22
lines changed

cli/exp.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ func (r *RootCmd) expCmd() *serpent.Command {
1616
r.mcpCommand(),
1717
r.promptExample(),
1818
r.rptyCommand(),
19+
r.tasksCommand(),
1920
},
2021
}
2122
return cmd

cli/exp_tasks.go

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"time"
7+
8+
"golang.org/x/xerrors"
9+
10+
"github.com/coder/coder/v2/cli/cliui"
11+
"github.com/coder/coder/v2/codersdk"
12+
"github.com/coder/serpent"
13+
)
14+
15+
func (r *RootCmd) tasksCommand() *serpent.Command {
16+
cmd := &serpent.Command{
17+
Use: "task",
18+
Aliases: []string{"tasks"},
19+
Short: "Experimental task commands.",
20+
Handler: func(i *serpent.Invocation) error {
21+
return i.Command.HelpHandler(i)
22+
},
23+
Children: []*serpent.Command{
24+
r.tasksListCmd(),
25+
},
26+
}
27+
return cmd
28+
}
29+
30+
type taskListRow struct {
31+
Task codersdk.Task `table:"t,recursive_inline"`
32+
33+
StateChanged string `table:"state changed"`
34+
}
35+
36+
func taskListRowFromTask(now time.Time, t codersdk.Task) taskListRow {
37+
var stateAgo string
38+
if t.CurrentState != nil {
39+
stateAgo = now.UTC().Sub(t.CurrentState.Timestamp).Truncate(time.Second).String() + " ago"
40+
}
41+
42+
return taskListRow{
43+
Task: t,
44+
45+
StateChanged: stateAgo,
46+
}
47+
}
48+
49+
func (r *RootCmd) tasksListCmd() *serpent.Command {
50+
var (
51+
statusFilter string
52+
all bool
53+
user string
54+
55+
client = new(codersdk.Client)
56+
formatter = cliui.NewOutputFormatter(
57+
cliui.TableFormat(
58+
[]taskListRow{},
59+
[]string{
60+
"id",
61+
"name",
62+
"status",
63+
"state",
64+
"state changed",
65+
"message",
66+
},
67+
),
68+
cliui.ChangeFormatterData(
69+
cliui.JSONFormat(),
70+
func(data any) (any, error) {
71+
rows, ok := data.([]taskListRow)
72+
if !ok {
73+
return nil, xerrors.Errorf("expected []taskListRow, got %T", data)
74+
}
75+
out := make([]codersdk.Task, len(rows))
76+
for i := range rows {
77+
out[i] = rows[i].Task
78+
}
79+
return out, nil
80+
},
81+
),
82+
)
83+
)
84+
85+
cmd := &serpent.Command{
86+
Use: "list",
87+
Short: "List experimental tasks",
88+
Aliases: []string{"ls"},
89+
Middleware: serpent.Chain(
90+
serpent.RequireNArgs(0),
91+
r.InitClient(client),
92+
),
93+
Options: serpent.OptionSet{
94+
{
95+
Name: "status",
96+
Description: "Filter by task status (e.g. running, failed, etc).",
97+
Flag: "status",
98+
Default: "",
99+
Value: serpent.StringOf(&statusFilter),
100+
},
101+
{
102+
Name: "all",
103+
Description: "List tasks for all users you can view.",
104+
Flag: "all",
105+
FlagShorthand: "a",
106+
Default: "false",
107+
Value: serpent.BoolOf(&all),
108+
},
109+
{
110+
Name: "user",
111+
Description: "List tasks for the specified user (username, \"me\").",
112+
Flag: "user",
113+
Default: "",
114+
Value: serpent.StringOf(&user),
115+
},
116+
},
117+
Handler: func(inv *serpent.Invocation) error {
118+
ctx := inv.Context()
119+
exp := codersdk.NewExperimentalClient(client)
120+
121+
targetUser := strings.TrimSpace(user)
122+
if targetUser == "" && !all {
123+
targetUser = codersdk.Me
124+
}
125+
126+
tasks, err := exp.Tasks(ctx, &codersdk.TasksFilter{
127+
Owner: targetUser,
128+
Status: statusFilter,
129+
})
130+
if err != nil {
131+
return xerrors.Errorf("list tasks: %w", err)
132+
}
133+
134+
// If no rows and not JSON, show a friendly message.
135+
if len(tasks) == 0 && formatter.FormatID() != cliui.JSONFormat().ID() {
136+
_, _ = fmt.Fprintln(inv.Stderr, "No tasks found.")
137+
return nil
138+
}
139+
140+
rows := make([]taskListRow, len(tasks))
141+
now := time.Now()
142+
for i := range tasks {
143+
rows[i] = taskListRowFromTask(now, tasks[i])
144+
}
145+
146+
out, err := formatter.Format(ctx, rows)
147+
if err != nil {
148+
return xerrors.Errorf("format tasks: %w", err)
149+
}
150+
_, _ = fmt.Fprintln(inv.Stdout, out)
151+
return nil
152+
},
153+
}
154+
155+
formatter.AttachOptions(&cmd.Options)
156+
return cmd
157+
}

coderd/coderd.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1008,10 +1008,10 @@ func New(options *Options) *API {
10081008
r.Route("/tasks", func(r chi.Router) {
10091009
r.Use(apiRateLimiter)
10101010

1011+
r.Get("/", api.tasksList)
1012+
10111013
r.Route("/{user}", func(r chi.Router) {
10121014
r.Use(httpmw.ExtractOrganizationMembersParam(options.Database, api.HTTPAuth.Authorize))
1013-
1014-
r.Get("/", api.tasksList)
10151015
r.Get("/{id}", api.taskGet)
10161016
r.Post("/", api.tasksCreate)
10171017
})

codersdk/aitasks.go

Lines changed: 30 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -88,35 +88,41 @@ const (
8888
//
8989
// Experimental: This type is experimental and may change in the future.
9090
type Task struct {
91-
ID uuid.UUID `json:"id" format:"uuid"`
92-
OrganizationID uuid.UUID `json:"organization_id" format:"uuid"`
93-
OwnerID uuid.UUID `json:"owner_id" format:"uuid"`
94-
Name string `json:"name"`
95-
TemplateID uuid.UUID `json:"template_id" format:"uuid"`
96-
WorkspaceID uuid.NullUUID `json:"workspace_id" format:"uuid"`
97-
InitialPrompt string `json:"initial_prompt"`
98-
Status WorkspaceStatus `json:"status" enums:"pending,starting,running,stopping,stopped,failed,canceling,canceled,deleting,deleted"`
99-
CurrentState *TaskStateEntry `json:"current_state"`
100-
CreatedAt time.Time `json:"created_at" format:"date-time"`
101-
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
91+
ID uuid.UUID `json:"id" format:"uuid" table:"id"`
92+
OrganizationID uuid.UUID `json:"organization_id" format:"uuid" table:"organization id"`
93+
OwnerID uuid.UUID `json:"owner_id" format:"uuid" table:"owner id"`
94+
Name string `json:"name" table:"name,default_sort"`
95+
TemplateID uuid.UUID `json:"template_id" format:"uuid" table:"template id"`
96+
WorkspaceID uuid.NullUUID `json:"workspace_id" format:"uuid" table:"workspace id"`
97+
InitialPrompt string `json:"initial_prompt" table:"initial prompt"`
98+
Status WorkspaceStatus `json:"status" enums:"pending,starting,running,stopping,stopped,failed,canceling,canceled,deleting,deleted" table:"status"`
99+
CurrentState *TaskStateEntry `json:"current_state" table:"cs,recursive_inline"`
100+
CreatedAt time.Time `json:"created_at" format:"date-time" table:"created at"`
101+
UpdatedAt time.Time `json:"updated_at" format:"date-time" table:"updated at"`
102102
}
103103

104104
// TaskStateEntry represents a single entry in the task's state history.
105105
//
106106
// Experimental: This type is experimental and may change in the future.
107107
type TaskStateEntry struct {
108-
Timestamp time.Time `json:"timestamp" format:"date-time"`
109-
State TaskState `json:"state" enum:"working,idle,completed,failed"`
110-
Message string `json:"message"`
111-
URI string `json:"uri"`
108+
Timestamp time.Time `json:"timestamp" format:"date-time" table:"-"`
109+
State TaskState `json:"state" enum:"working,idle,completed,failed" table:"state"`
110+
Message string `json:"message" table:"message"`
111+
URI string `json:"uri" table:"-"`
112112
}
113113

114114
// TasksFilter filters the list of tasks.
115115
//
116116
// Experimental: This type is experimental and may change in the future.
117117
type TasksFilter struct {
118-
// Owner can be a username, UUID, or "me"
118+
// Owner can be a username, UUID, or "me".
119119
Owner string `json:"owner,omitempty"`
120+
// Status is a task status.
121+
Status string `json:"status,omitempty" typescript:"-"`
122+
// Offset is the number of workspaces to skip before returning results.
123+
Offset int `json:"offset,omitempty" typescript:"-"`
124+
// Limit is a limit on the number of workspaces returned.
125+
Limit int `json:"limit,omitempty" typescript:"-"`
120126
}
121127

122128
// Tasks lists all tasks belonging to the user or specified owner.
@@ -126,12 +132,16 @@ func (c *ExperimentalClient) Tasks(ctx context.Context, filter *TasksFilter) ([]
126132
if filter == nil {
127133
filter = &TasksFilter{}
128134
}
129-
user := filter.Owner
130-
if user == "" {
131-
user = "me"
135+
136+
var wsFilter WorkspaceFilter
137+
wsFilter.Owner = filter.Owner
138+
wsFilter.Status = filter.Status
139+
page := Pagination{
140+
Offset: filter.Offset,
141+
Limit: filter.Limit,
132142
}
133143

134-
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/tasks/%s", user), nil)
144+
res, err := c.Request(ctx, http.MethodGet, "/api/experimental/tasks", nil, wsFilter.asRequestOption(), page.asRequestOption())
135145
if err != nil {
136146
return nil, err
137147
}

0 commit comments

Comments
 (0)