diff --git a/cli/exp.go b/cli/exp.go index dafd85402663e..e20d1e28d5ffe 100644 --- a/cli/exp.go +++ b/cli/exp.go @@ -16,6 +16,7 @@ func (r *RootCmd) expCmd() *serpent.Command { r.mcpCommand(), r.promptExample(), r.rptyCommand(), + r.tasksCommand(), }, } return cmd diff --git a/cli/exp_task.go b/cli/exp_task.go new file mode 100644 index 0000000000000..81316d155000d --- /dev/null +++ b/cli/exp_task.go @@ -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 +} diff --git a/cli/exp_tasklist.go b/cli/exp_tasklist.go new file mode 100644 index 0000000000000..7f2b44d25aa4c --- /dev/null +++ b/cli/exp_tasklist.go @@ -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 +} diff --git a/cli/exp_tasklist_test.go b/cli/exp_tasklist_test.go new file mode 100644 index 0000000000000..1120a11c69e3c --- /dev/null +++ b/cli/exp_tasklist_test.go @@ -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") + }) +} diff --git a/coderd/aitasks.go b/coderd/aitasks.go index de607e7619f77..45df5fa68f336 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -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 = "" } diff --git a/coderd/coderd.go b/coderd/coderd.go index bb6f7b4fef4e5..8b3639e5ae026 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -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) }) diff --git a/codersdk/aitasks.go b/codersdk/aitasks.go index 965b0fac1d493..d666f63df0fbc 100644 --- a/codersdk/aitasks.go +++ b/codersdk/aitasks.go @@ -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 }