diff --git a/cli/exp_task_delete.go b/cli/exp_task_delete.go index 43675057bd..1611e4196e 100644 --- a/cli/exp_task_delete.go +++ b/cli/exp_task_delete.go @@ -5,7 +5,6 @@ import ( "strings" "time" - "github.com/google/uuid" "golang.org/x/xerrors" "github.com/coder/pretty" @@ -47,43 +46,19 @@ func (r *RootCmd) taskDelete() *serpent.Command { } exp := codersdk.NewExperimentalClient(client) - type toDelete struct { - ID uuid.UUID - Owner string - Display string - } - - var items []toDelete + var tasks []codersdk.Task for _, identifier := range inv.Args { - identifier = strings.TrimSpace(identifier) - if identifier == "" { - return xerrors.New("task identifier cannot be empty or whitespace") - } - - // Check task identifier, try UUID first. - if id, err := uuid.Parse(identifier); err == nil { - task, err := exp.TaskByID(ctx, id) - if err != nil { - return xerrors.Errorf("resolve task %q: %w", identifier, err) - } - display := fmt.Sprintf("%s/%s", task.OwnerName, task.Name) - items = append(items, toDelete{ID: id, Display: display, Owner: task.OwnerName}) - continue - } - - // Non-UUID, treat as a workspace identifier (name or owner/name). - ws, err := namedWorkspace(ctx, client, identifier) + task, err := exp.TaskByIdentifier(ctx, identifier) if err != nil { return xerrors.Errorf("resolve task %q: %w", identifier, err) } - display := ws.FullName() - items = append(items, toDelete{ID: ws.ID, Display: display, Owner: ws.OwnerName}) + tasks = append(tasks, task) } // Confirm deletion of the tasks. var displayList []string - for _, it := range items { - displayList = append(displayList, it.Display) + for _, task := range tasks { + displayList = append(displayList, fmt.Sprintf("%s/%s", task.OwnerName, task.Name)) } _, err = cliui.Prompt(inv, cliui.PromptOptions{ Text: fmt.Sprintf("Delete these tasks: %s?", pretty.Sprint(cliui.DefaultStyles.Code, strings.Join(displayList, ", "))), @@ -94,12 +69,13 @@ func (r *RootCmd) taskDelete() *serpent.Command { return err } - for _, item := range items { - if err := exp.DeleteTask(ctx, item.Owner, item.ID); err != nil { - return xerrors.Errorf("delete task %q: %w", item.Display, err) + for i, task := range tasks { + display := displayList[i] + if err := exp.DeleteTask(ctx, task.OwnerName, task.ID); err != nil { + return xerrors.Errorf("delete task %q: %w", display, err) } _, _ = fmt.Fprintln( - inv.Stdout, "Deleted task "+pretty.Sprint(cliui.DefaultStyles.Keyword, item.Display)+" at "+cliui.Timestamp(time.Now()), + inv.Stdout, "Deleted task "+pretty.Sprint(cliui.DefaultStyles.Keyword, display)+" at "+cliui.Timestamp(time.Now()), ) } diff --git a/cli/exp_task_delete_test.go b/cli/exp_task_delete_test.go index 0b288c4ca3..e90ee8c5b1 100644 --- a/cli/exp_task_delete_test.go +++ b/cli/exp_task_delete_test.go @@ -56,12 +56,18 @@ func TestExpTaskDelete(t *testing.T) { taskID := uuid.MustParse(id1) return func(w http.ResponseWriter, r *http.Request) { switch { - case r.Method == http.MethodGet && r.URL.Path == "/api/v2/users/me/workspace/exists": + case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks" && r.URL.Query().Get("q") == "owner:\"me\"": c.nameResolves.Add(1) - httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Workspace{ - ID: taskID, - Name: "exists", - OwnerName: "me", + httpapi.Write(r.Context(), w, http.StatusOK, struct { + Tasks []codersdk.Task `json:"tasks"` + Count int `json:"count"` + }{ + Tasks: []codersdk.Task{{ + ID: taskID, + Name: "exists", + OwnerName: "me", + }}, + Count: 1, }) case r.Method == http.MethodDelete && r.URL.Path == "/api/experimental/tasks/me/"+id1: c.deleteCalls.Add(1) @@ -104,12 +110,18 @@ func TestExpTaskDelete(t *testing.T) { firstID := uuid.MustParse(id3) return func(w http.ResponseWriter, r *http.Request) { switch { - case r.Method == http.MethodGet && r.URL.Path == "/api/v2/users/me/workspace/first": + case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks" && r.URL.Query().Get("q") == "owner:\"me\"": c.nameResolves.Add(1) - httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Workspace{ - ID: firstID, - Name: "first", - OwnerName: "me", + httpapi.Write(r.Context(), w, http.StatusOK, struct { + Tasks []codersdk.Task `json:"tasks"` + Count int `json:"count"` + }{ + Tasks: []codersdk.Task{{ + ID: firstID, + Name: "first", + OwnerName: "me", + }}, + Count: 1, }) case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks/me/"+id4: httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Task{ @@ -139,8 +151,14 @@ func TestExpTaskDelete(t *testing.T) { buildHandler: func(_ *testCounters) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { switch { - case r.Method == http.MethodGet && r.URL.Path == "/api/v2/users/me/workspace/doesnotexist": - httpapi.ResourceNotFound(w) + case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks" && r.URL.Query().Get("q") == "owner:\"me\"": + httpapi.Write(r.Context(), w, http.StatusOK, struct { + Tasks []codersdk.Task `json:"tasks"` + Count int `json:"count"` + }{ + Tasks: []codersdk.Task{}, + Count: 0, + }) default: httpapi.InternalServerError(w, xerrors.New("unwanted path: "+r.Method+" "+r.URL.Path)) } @@ -156,12 +174,18 @@ func TestExpTaskDelete(t *testing.T) { taskID := uuid.MustParse(id5) return func(w http.ResponseWriter, r *http.Request) { switch { - case r.Method == http.MethodGet && r.URL.Path == "/api/v2/users/me/workspace/bad": + case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks" && r.URL.Query().Get("q") == "owner:\"me\"": c.nameResolves.Add(1) - httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Workspace{ - ID: taskID, - Name: "bad", - OwnerName: "me", + httpapi.Write(r.Context(), w, http.StatusOK, struct { + Tasks []codersdk.Task `json:"tasks"` + Count int `json:"count"` + }{ + Tasks: []codersdk.Task{{ + ID: taskID, + Name: "bad", + OwnerName: "me", + }}, + Count: 1, }) case r.Method == http.MethodDelete && r.URL.Path == "/api/experimental/tasks/me/"+id5: httpapi.InternalServerError(w, xerrors.New("boom")) diff --git a/cli/exp_task_list.go b/cli/exp_task_list.go index 442718814b..89b313a1f4 100644 --- a/cli/exp_task_list.go +++ b/cli/exp_task_list.go @@ -8,6 +8,7 @@ import ( "golang.org/x/xerrors" "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" "github.com/coder/serpent" ) @@ -98,10 +99,10 @@ func (r *RootCmd) taskList() *serpent.Command { Options: serpent.OptionSet{ { Name: "status", - Description: "Filter by task status (e.g. running, failed, etc).", + Description: "Filter by task status.", Flag: "status", Default: "", - Value: serpent.StringOf(&statusFilter), + Value: serpent.EnumOf(&statusFilter, slice.ToStrings(codersdk.AllTaskStatuses())...), }, { Name: "all", @@ -142,8 +143,8 @@ func (r *RootCmd) taskList() *serpent.Command { } tasks, err := exp.Tasks(ctx, &codersdk.TasksFilter{ - Owner: targetUser, - WorkspaceStatus: statusFilter, + Owner: targetUser, + Status: codersdk.TaskStatus(statusFilter), }) if err != nil { return xerrors.Errorf("list tasks: %w", err) diff --git a/cli/exp_task_list_test.go b/cli/exp_task_list_test.go index f8f20eacaf..d1e0862dc7 100644 --- a/cli/exp_task_list_test.go +++ b/cli/exp_task_list_test.go @@ -22,6 +22,7 @@ import ( "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/database/dbtime" "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/pty/ptytest" @@ -29,7 +30,7 @@ import ( ) // 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) { +func makeAITask(t *testing.T, db database.Store, orgID, adminID, ownerID uuid.UUID, transition database.WorkspaceTransition, prompt string) database.Task { t.Helper() tv := dbfake.TemplateVersion(t, db). @@ -91,14 +92,32 @@ func makeAITask(t *testing.T, db database.Store, orgID, adminID, ownerID uuid.UU ) require.NoError(t, err) - return build.Workspace + // Create a task record in the tasks table for the new data model. + task := dbgen.Task(t, db, database.TaskTable{ + OrganizationID: orgID, + OwnerID: ownerID, + Name: build.Workspace.Name, + WorkspaceID: uuid.NullUUID{UUID: build.Workspace.ID, Valid: true}, + TemplateVersionID: tv.TemplateVersion.ID, + TemplateParameters: []byte("{}"), + Prompt: prompt, + CreatedAt: dbtime.Now(), + }) + + // Link the task to the workspace app. + dbgen.TaskWorkspaceApp(t, db, database.TaskWorkspaceApp{ + TaskID: task.ID, + WorkspaceBuildNumber: build.Build.BuildNumber, + WorkspaceAgentID: uuid.NullUUID{UUID: agentID, Valid: true}, + WorkspaceAppID: uuid.NullUUID{UUID: app.ID, Valid: true}, + }) + + return task } func TestExpTaskList(t *testing.T) { t.Parallel() - t.Skip("TODO(mafredri): Remove, fixed down-stack!") - t.Run("NoTasks_Table", func(t *testing.T) { t.Parallel() @@ -130,7 +149,7 @@ func TestExpTaskList(t *testing.T) { 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) + task := 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) @@ -142,7 +161,7 @@ func TestExpTaskList(t *testing.T) { require.NoError(t, err) // Validate the table includes the task and status. - pty.ExpectMatch(ws.Name) + pty.ExpectMatch(task.Name) pty.ExpectMatch("running") pty.ExpectMatch(wantPrompt) }) @@ -157,11 +176,11 @@ func TestExpTaskList(t *testing.T) { 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") + runningTask := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStart, "keep me running") + stoppedTask := 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") + inv, root := clitest.New(t, "exp", "task", "list", "--status=paused", "--output=json") clitest.SetupConfig(t, memberClient, root) ctx := testutil.Context(t, testutil.WaitShort) @@ -177,8 +196,8 @@ func TestExpTaskList(t *testing.T) { // 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) + require.Equal(t, stoppedTask.ID, tasks[0].ID) + require.NotEqual(t, runningTask.ID, tasks[0].ID) }) t.Run("UserFlag_Me_Table", func(t *testing.T) { @@ -190,7 +209,7 @@ func TestExpTaskList(t *testing.T) { _, 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") + task := 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. @@ -202,7 +221,7 @@ func TestExpTaskList(t *testing.T) { err := inv.WithContext(ctx).Run() require.NoError(t, err) - pty.ExpectMatch(ws.Name) + pty.ExpectMatch(task.Name) }) t.Run("Quiet", func(t *testing.T) { diff --git a/cli/exp_task_logs.go b/cli/exp_task_logs.go index 6c99df3edf..d1d4a826cd 100644 --- a/cli/exp_task_logs.go +++ b/cli/exp_task_logs.go @@ -3,7 +3,6 @@ package cli import ( "fmt" - "github.com/google/uuid" "golang.org/x/xerrors" "github.com/coder/coder/v2/cli/cliui" @@ -41,24 +40,17 @@ func (r *RootCmd) taskLogs() *serpent.Command { } var ( - ctx = inv.Context() - exp = codersdk.NewExperimentalClient(client) - task = inv.Args[0] - taskID uuid.UUID + ctx = inv.Context() + exp = codersdk.NewExperimentalClient(client) + identifier = inv.Args[0] ) - if id, err := uuid.Parse(task); err == nil { - taskID = id - } else { - ws, err := namedWorkspace(ctx, client, task) - if err != nil { - return xerrors.Errorf("resolve task %q: %w", task, err) - } - - taskID = ws.ID + task, err := exp.TaskByIdentifier(ctx, identifier) + if err != nil { + return xerrors.Errorf("resolve task %q: %w", identifier, err) } - logs, err := exp.TaskLogs(ctx, codersdk.Me, taskID) + logs, err := exp.TaskLogs(ctx, codersdk.Me, task.ID) if err != nil { return xerrors.Errorf("get task logs: %w", err) } diff --git a/cli/exp_task_logs_test.go b/cli/exp_task_logs_test.go index 5137c8b40f..859ff135d0 100644 --- a/cli/exp_task_logs_test.go +++ b/cli/exp_task_logs_test.go @@ -23,8 +23,6 @@ import ( func Test_TaskLogs(t *testing.T) { t.Parallel() - t.Skip("TODO(mafredri): Remove, fixed up-stack!") - testMessages := []agentapisdk.Message{ { Id: 0, @@ -40,15 +38,15 @@ func Test_TaskLogs(t *testing.T) { }, } - t.Run("ByWorkspaceName_JSON", func(t *testing.T) { + t.Run("ByTaskName_JSON", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - client, workspace := setupCLITaskTest(ctx, t, fakeAgentAPITaskLogsOK(testMessages)) + client, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskLogsOK(testMessages)) userClient := client // user already has access to their own workspace var stdout strings.Builder - inv, root := clitest.New(t, "exp", "task", "logs", workspace.Name, "--output", "json") + inv, root := clitest.New(t, "exp", "task", "logs", task.Name, "--output", "json") inv.Stdout = &stdout clitest.SetupConfig(t, userClient, root) @@ -66,15 +64,15 @@ func Test_TaskLogs(t *testing.T) { require.Equal(t, codersdk.TaskLogTypeOutput, logs[1].Type) }) - t.Run("ByWorkspaceID_JSON", func(t *testing.T) { + t.Run("ByTaskID_JSON", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - client, workspace := setupCLITaskTest(ctx, t, fakeAgentAPITaskLogsOK(testMessages)) + client, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskLogsOK(testMessages)) userClient := client var stdout strings.Builder - inv, root := clitest.New(t, "exp", "task", "logs", workspace.ID.String(), "--output", "json") + inv, root := clitest.New(t, "exp", "task", "logs", task.ID.String(), "--output", "json") inv.Stdout = &stdout clitest.SetupConfig(t, userClient, root) @@ -92,15 +90,15 @@ func Test_TaskLogs(t *testing.T) { require.Equal(t, codersdk.TaskLogTypeOutput, logs[1].Type) }) - t.Run("ByWorkspaceID_Table", func(t *testing.T) { + t.Run("ByTaskID_Table", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - client, workspace := setupCLITaskTest(ctx, t, fakeAgentAPITaskLogsOK(testMessages)) + client, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskLogsOK(testMessages)) userClient := client var stdout strings.Builder - inv, root := clitest.New(t, "exp", "task", "logs", workspace.ID.String()) + inv, root := clitest.New(t, "exp", "task", "logs", task.ID.String()) inv.Stdout = &stdout clitest.SetupConfig(t, userClient, root) @@ -114,7 +112,7 @@ func Test_TaskLogs(t *testing.T) { require.Contains(t, output, "output") }) - t.Run("WorkspaceNotFound_ByName", func(t *testing.T) { + t.Run("TaskNotFound_ByName", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -132,7 +130,7 @@ func Test_TaskLogs(t *testing.T) { require.ErrorContains(t, err, httpapi.ResourceNotFoundResponse.Message) }) - t.Run("WorkspaceNotFound_ByID", func(t *testing.T) { + t.Run("TaskNotFound_ByID", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -154,10 +152,10 @@ func Test_TaskLogs(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - client, workspace := setupCLITaskTest(ctx, t, fakeAgentAPITaskLogsErr(assert.AnError)) + client, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskLogsErr(assert.AnError)) userClient := client - inv, root := clitest.New(t, "exp", "task", "logs", workspace.ID.String()) + inv, root := clitest.New(t, "exp", "task", "logs", task.ID.String()) clitest.SetupConfig(t, userClient, root) err := inv.WithContext(ctx).Run() diff --git a/cli/exp_task_send.go b/cli/exp_task_send.go index 4d9e10bbdd..e8985d55d9 100644 --- a/cli/exp_task_send.go +++ b/cli/exp_task_send.go @@ -3,7 +3,6 @@ package cli import ( "io" - "github.com/google/uuid" "golang.org/x/xerrors" "github.com/coder/coder/v2/codersdk" @@ -39,12 +38,11 @@ func (r *RootCmd) taskSend() *serpent.Command { } var ( - ctx = inv.Context() - exp = codersdk.NewExperimentalClient(client) - task = inv.Args[0] + ctx = inv.Context() + exp = codersdk.NewExperimentalClient(client) + identifier = inv.Args[0] taskInput string - taskID uuid.UUID ) if stdin { @@ -62,18 +60,12 @@ func (r *RootCmd) taskSend() *serpent.Command { taskInput = inv.Args[1] } - if id, err := uuid.Parse(task); err == nil { - taskID = id - } else { - ws, err := namedWorkspace(ctx, client, task) - if err != nil { - return xerrors.Errorf("resolve task: %w", err) - } - - taskID = ws.ID + task, err := exp.TaskByIdentifier(ctx, identifier) + if err != nil { + return xerrors.Errorf("resolve task: %w", err) } - if err = exp.TaskSend(ctx, codersdk.Me, taskID, codersdk.TaskSendRequest{Input: taskInput}); err != nil { + if err = exp.TaskSend(ctx, codersdk.Me, task.ID, codersdk.TaskSendRequest{Input: taskInput}); err != nil { return xerrors.Errorf("send input to task: %w", err) } diff --git a/cli/exp_task_send_test.go b/cli/exp_task_send_test.go index 26b69b992d..3529cf2e0b 100644 --- a/cli/exp_task_send_test.go +++ b/cli/exp_task_send_test.go @@ -22,17 +22,15 @@ import ( func Test_TaskSend(t *testing.T) { t.Parallel() - t.Skip("TODO(mafredri): Remove, fixed up-stack!") - - t.Run("ByWorkspaceName_WithArgument", func(t *testing.T) { + t.Run("ByTaskName_WithArgument", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - client, workspace := setupCLITaskTest(ctx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it")) + client, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it")) userClient := client var stdout strings.Builder - inv, root := clitest.New(t, "exp", "task", "send", workspace.Name, "carry on with the task") + inv, root := clitest.New(t, "exp", "task", "send", task.Name, "carry on with the task") inv.Stdout = &stdout clitest.SetupConfig(t, userClient, root) @@ -40,15 +38,15 @@ func Test_TaskSend(t *testing.T) { require.NoError(t, err) }) - t.Run("ByWorkspaceID_WithArgument", func(t *testing.T) { + t.Run("ByTaskID_WithArgument", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - client, workspace := setupCLITaskTest(ctx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it")) + client, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it")) userClient := client var stdout strings.Builder - inv, root := clitest.New(t, "exp", "task", "send", workspace.ID.String(), "carry on with the task") + inv, root := clitest.New(t, "exp", "task", "send", task.ID.String(), "carry on with the task") inv.Stdout = &stdout clitest.SetupConfig(t, userClient, root) @@ -56,15 +54,15 @@ func Test_TaskSend(t *testing.T) { require.NoError(t, err) }) - t.Run("ByWorkspaceName_WithStdin", func(t *testing.T) { + t.Run("ByTaskName_WithStdin", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - client, workspace := setupCLITaskTest(ctx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it")) + client, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it")) userClient := client var stdout strings.Builder - inv, root := clitest.New(t, "exp", "task", "send", workspace.Name, "--stdin") + inv, root := clitest.New(t, "exp", "task", "send", task.Name, "--stdin") inv.Stdout = &stdout inv.Stdin = strings.NewReader("carry on with the task") clitest.SetupConfig(t, userClient, root) @@ -73,7 +71,7 @@ func Test_TaskSend(t *testing.T) { require.NoError(t, err) }) - t.Run("WorkspaceNotFound_ByName", func(t *testing.T) { + t.Run("TaskNotFound_ByName", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -91,7 +89,7 @@ func Test_TaskSend(t *testing.T) { require.ErrorContains(t, err, httpapi.ResourceNotFoundResponse.Message) }) - t.Run("WorkspaceNotFound_ByID", func(t *testing.T) { + t.Run("TaskNotFound_ByID", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -113,10 +111,10 @@ func Test_TaskSend(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - userClient, workspace := setupCLITaskTest(ctx, t, fakeAgentAPITaskSendErr(t, assert.AnError)) + userClient, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskSendErr(t, assert.AnError)) var stdout strings.Builder - inv, root := clitest.New(t, "exp", "task", "send", workspace.Name, "some task input") + inv, root := clitest.New(t, "exp", "task", "send", task.Name, "some task input") inv.Stdout = &stdout clitest.SetupConfig(t, userClient, root) diff --git a/cli/exp_task_status.go b/cli/exp_task_status.go index 336efc1a99..f34812657a 100644 --- a/cli/exp_task_status.go +++ b/cli/exp_task_status.go @@ -5,7 +5,6 @@ import ( "strings" "time" - "github.com/google/uuid" "golang.org/x/xerrors" "github.com/coder/coder/v2/cli/cliui" @@ -84,21 +83,10 @@ func (r *RootCmd) taskStatus() *serpent.Command { } ctx := i.Context() - ec := codersdk.NewExperimentalClient(client) + exp := codersdk.NewExperimentalClient(client) identifier := i.Args[0] - taskID, err := uuid.Parse(identifier) - if err != nil { - // Try to resolve the task as a named workspace - // TODO: right now tasks are still "workspaces" under the hood. - // We should update this once we have a proper task model. - ws, err := namedWorkspace(ctx, client, identifier) - if err != nil { - return err - } - taskID = ws.ID - } - task, err := ec.TaskByID(ctx, taskID) + task, err := exp.TaskByIdentifier(ctx, identifier) if err != nil { return err } @@ -119,7 +107,7 @@ func (r *RootCmd) taskStatus() *serpent.Command { // TODO: implement streaming updates instead of polling lastStatusRow := tsr for range t.C { - task, err := ec.TaskByID(ctx, taskID) + task, err := exp.TaskByID(ctx, task.ID) if err != nil { return err } diff --git a/cli/exp_task_status_test.go b/cli/exp_task_status_test.go index d5b4e0a9d6..01b2a8379d 100644 --- a/cli/exp_task_status_test.go +++ b/cli/exp_task_status_test.go @@ -36,26 +36,17 @@ func Test_TaskStatus(t *testing.T) { hf: func(ctx context.Context, _ time.Time) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { - case "/api/v2/users/me/workspace/doesnotexist": - httpapi.ResourceNotFound(w) - default: - t.Errorf("unexpected path: %s", r.URL.Path) - } - } - }, - }, - { - args: []string{"err-fetching-workspace"}, - expectError: assert.AnError.Error(), - hf: func(ctx context.Context, _ time.Time) func(w http.ResponseWriter, r *http.Request) { - return func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/users/me/workspace/err-fetching-workspace": - httpapi.Write(ctx, w, http.StatusOK, codersdk.Workspace{ - ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), - }) - case "/api/experimental/tasks/me/11111111-1111-1111-1111-111111111111": - httpapi.InternalServerError(w, assert.AnError) + case "/api/experimental/tasks": + if r.URL.Query().Get("q") == "owner:\"me\"" { + httpapi.Write(ctx, w, http.StatusOK, struct { + Tasks []codersdk.Task `json:"tasks"` + Count int `json:"count"` + }{ + Tasks: []codersdk.Task{}, + Count: 0, + }) + return + } default: t.Errorf("unexpected path: %s", r.URL.Path) } @@ -69,10 +60,34 @@ func Test_TaskStatus(t *testing.T) { hf: func(ctx context.Context, now time.Time) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { - case "/api/v2/users/me/workspace/exists": - httpapi.Write(ctx, w, http.StatusOK, codersdk.Workspace{ - ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), - }) + case "/api/experimental/tasks": + if r.URL.Query().Get("q") == "owner:\"me\"" { + httpapi.Write(ctx, w, http.StatusOK, struct { + Tasks []codersdk.Task `json:"tasks"` + Count int `json:"count"` + }{ + Tasks: []codersdk.Task{{ + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + Name: "exists", + OwnerName: "me", + WorkspaceStatus: codersdk.WorkspaceStatusRunning, + CreatedAt: now, + UpdatedAt: now, + CurrentState: &codersdk.TaskStateEntry{ + State: codersdk.TaskStateWorking, + Timestamp: now, + Message: "Thinking furiously...", + }, + WorkspaceAgentHealth: &codersdk.WorkspaceAgentHealth{ + Healthy: true, + }, + WorkspaceAgentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleReady), + Status: codersdk.TaskStatusActive, + }}, + Count: 1, + }) + return + } case "/api/experimental/tasks/me/11111111-1111-1111-1111-111111111111": httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{ ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), @@ -101,31 +116,54 @@ func Test_TaskStatus(t *testing.T) { args: []string{"exists", "--watch"}, expectOutput: ` STATE CHANGED STATUS HEALTHY STATE MESSAGE +5s ago pending true 4s ago running true 3s ago running true working Reticulating splines... 2s ago running true complete Splines reticulated successfully!`, hf: func(ctx context.Context, now time.Time) func(http.ResponseWriter, *http.Request) { var calls atomic.Int64 return func(w http.ResponseWriter, r *http.Request) { - defer calls.Add(1) switch r.URL.Path { - case "/api/v2/users/me/workspace/exists": - httpapi.Write(ctx, w, http.StatusOK, codersdk.Workspace{ - ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), - }) + case "/api/experimental/tasks": + if r.URL.Query().Get("q") == "owner:\"me\"" { + // Return initial task state for --watch test + httpapi.Write(ctx, w, http.StatusOK, struct { + Tasks []codersdk.Task `json:"tasks"` + Count int `json:"count"` + }{ + Tasks: []codersdk.Task{{ + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + Name: "exists", + OwnerName: "me", + WorkspaceStatus: codersdk.WorkspaceStatusPending, + CreatedAt: now.Add(-5 * time.Second), + UpdatedAt: now.Add(-5 * time.Second), + WorkspaceAgentHealth: &codersdk.WorkspaceAgentHealth{ + Healthy: true, + }, + WorkspaceAgentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleReady), + Status: codersdk.TaskStatusPending, + }}, + Count: 1, + }) + return + } case "/api/experimental/tasks/me/11111111-1111-1111-1111-111111111111": + defer calls.Add(1) switch calls.Load() { case 0: httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{ ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), - WorkspaceStatus: codersdk.WorkspaceStatusPending, + Name: "exists", + OwnerName: "me", + WorkspaceStatus: codersdk.WorkspaceStatusRunning, CreatedAt: now.Add(-5 * time.Second), - UpdatedAt: now.Add(-5 * time.Second), + UpdatedAt: now.Add(-4 * time.Second), WorkspaceAgentHealth: &codersdk.WorkspaceAgentHealth{ Healthy: true, }, WorkspaceAgentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleReady), - Status: codersdk.TaskStatusPending, + Status: codersdk.TaskStatusInitializing, }) return case 1: @@ -194,8 +232,8 @@ STATE CHANGED STATUS HEALTHY STATE MESSAGE "id": "11111111-1111-1111-1111-111111111111", "organization_id": "00000000-0000-0000-0000-000000000000", "owner_id": "00000000-0000-0000-0000-000000000000", - "owner_name": "", - "name": "", + "owner_name": "me", + "name": "exists", "template_id": "00000000-0000-0000-0000-000000000000", "template_version_id": "00000000-0000-0000-0000-000000000000", "template_name": "", @@ -204,8 +242,10 @@ STATE CHANGED STATUS HEALTHY STATE MESSAGE "workspace_id": null, "workspace_status": "running", "workspace_agent_id": null, - "workspace_agent_lifecycle": null, - "workspace_agent_health": null, + "workspace_agent_lifecycle": "ready", + "workspace_agent_health": { + "healthy": true + }, "workspace_app_id": null, "initial_prompt": "", "status": "active", @@ -218,14 +258,38 @@ STATE CHANGED STATUS HEALTHY STATE MESSAGE "created_at": "2025-08-26T12:34:56Z", "updated_at": "2025-08-26T12:34:56Z" }`, - hf: func(ctx context.Context, _ time.Time) func(w http.ResponseWriter, r *http.Request) { + hf: func(ctx context.Context, now time.Time) func(http.ResponseWriter, *http.Request) { ts := time.Date(2025, 8, 26, 12, 34, 56, 0, time.UTC) return func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { - case "/api/v2/users/me/workspace/exists": - httpapi.Write(ctx, w, http.StatusOK, codersdk.Workspace{ - ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), - }) + case "/api/experimental/tasks": + if r.URL.Query().Get("q") == "owner:\"me\"" { + httpapi.Write(ctx, w, http.StatusOK, struct { + Tasks []codersdk.Task `json:"tasks"` + Count int `json:"count"` + }{ + Tasks: []codersdk.Task{{ + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + Name: "exists", + OwnerName: "me", + WorkspaceStatus: codersdk.WorkspaceStatusRunning, + CreatedAt: ts, + UpdatedAt: ts, + CurrentState: &codersdk.TaskStateEntry{ + State: codersdk.TaskStateWorking, + Timestamp: ts.Add(time.Second), + Message: "Thinking furiously...", + }, + WorkspaceAgentHealth: &codersdk.WorkspaceAgentHealth{ + Healthy: true, + }, + WorkspaceAgentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleReady), + Status: codersdk.TaskStatusActive, + }}, + Count: 1, + }) + return + } case "/api/experimental/tasks/me/11111111-1111-1111-1111-111111111111": httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{ ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), diff --git a/cli/exp_task_test.go b/cli/exp_task_test.go index 55ee458fb2..82457ec748 100644 --- a/cli/exp_task_test.go +++ b/cli/exp_task_test.go @@ -17,7 +17,6 @@ import ( "golang.org/x/xerrors" agentapisdk "github.com/coder/agentapi-sdk-go" - "github.com/coder/coder/v2/agent" "github.com/coder/coder/v2/agent/agenttest" "github.com/coder/coder/v2/cli/clitest" @@ -36,8 +35,6 @@ import ( func Test_Tasks(t *testing.T) { t.Parallel() - t.Skip("TODO(mafredri): Remove, fixed up-stack!") - // Given: a template configured for tasks var ( ctx = testutil.Context(t, testutil.WaitLong) @@ -241,7 +238,7 @@ 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.Workspace) { +func setupCLITaskTest(ctx context.Context, t *testing.T, agentAPIHandlers map[string]http.HandlerFunc) (*codersdk.Client, codersdk.Task) { t.Helper() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) @@ -254,11 +251,18 @@ func setupCLITaskTest(ctx context.Context, t *testing.T, agentAPIHandlers map[st template := createAITaskTemplate(t, client, owner.OrganizationID, withSidebarURL(fakeAPI.URL()), withAgentToken(authToken)) wantPrompt := "test prompt" - workspace := coderdtest.CreateWorkspace(t, userClient, template.ID, func(req *codersdk.CreateWorkspaceRequest) { - req.RichParameterValues = []codersdk.WorkspaceBuildParameter{ - {Name: codersdk.AITaskPromptParameterName, Value: wantPrompt}, - } + exp := codersdk.NewExperimentalClient(userClient) + task, err := exp.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{ + TemplateVersionID: template.ActiveVersionID, + Input: wantPrompt, + Name: "test-task", }) + require.NoError(t, err) + + // Wait for the task's underlying workspace to be built + 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) agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(authToken)) @@ -269,7 +273,7 @@ func setupCLITaskTest(ctx context.Context, t *testing.T, agentAPIHandlers map[st coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID). WaitFor(coderdtest.AgentsReady) - return userClient, workspace + return userClient, task } // createAITaskTemplate creates a template configured for AI tasks with a sidebar app. diff --git a/coderd/aitasks.go b/coderd/aitasks.go index e5f5260a24..272bdba9c1 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -2,7 +2,6 @@ package coderd import ( "context" - "database/sql" "fmt" "net" "net/http" @@ -12,6 +11,7 @@ import ( "time" "github.com/google/uuid" + "golang.org/x/xerrors" "cdr.dev/slog" "github.com/coder/coder/v2/coderd/audit" @@ -360,107 +360,6 @@ func taskFromDBTaskAndWorkspace(dbTask database.Task, ws codersdk.Workspace) cod } } -func taskFromWorkspace(ws codersdk.Workspace, initialPrompt string) codersdk.Task { - // TODO(DanielleMaywood): - // This just picks up the first agent it discovers. - // This approach _might_ break when a task has multiple agents, - // depending on which agent was found first. - // - // We explicitly do not have support for running tasks - // inside of a sub agent at the moment, so we can be sure - // that any sub agents are not the agent we're looking for. - var taskAgentID uuid.NullUUID - var taskAgentLifecycle *codersdk.WorkspaceAgentLifecycle - var taskAgentHealth *codersdk.WorkspaceAgentHealth - for _, resource := range ws.LatestBuild.Resources { - for _, agent := range resource.Agents { - if agent.ParentID.Valid { - continue - } - - taskAgentID = uuid.NullUUID{Valid: true, UUID: agent.ID} - taskAgentLifecycle = &agent.LifecycleState - taskAgentHealth = &agent.Health - break - } - } - - // Ignore 'latest app status' if it is older than the latest build and the - // latest build is a 'start' transition. This ensures that you don't show a - // stale app status from a previous build. For stop transitions, there is - // still value in showing the latest app status. - var currentState *codersdk.TaskStateEntry - if ws.LatestAppStatus != nil { - if ws.LatestBuild.Transition != codersdk.WorkspaceTransitionStart || ws.LatestAppStatus.CreatedAt.After(ws.LatestBuild.CreatedAt) { - currentState = &codersdk.TaskStateEntry{ - Timestamp: ws.LatestAppStatus.CreatedAt, - State: codersdk.TaskState(ws.LatestAppStatus.State), - Message: ws.LatestAppStatus.Message, - URI: ws.LatestAppStatus.URI, - } - } - } - - var appID uuid.NullUUID - if ws.LatestBuild.AITaskSidebarAppID != nil { - appID = uuid.NullUUID{ - Valid: true, - UUID: *ws.LatestBuild.AITaskSidebarAppID, - } - } - - return codersdk.Task{ - ID: ws.ID, - OrganizationID: ws.OrganizationID, - OwnerID: ws.OwnerID, - OwnerName: ws.OwnerName, - Name: ws.Name, - TemplateID: ws.TemplateID, - TemplateName: ws.TemplateName, - TemplateDisplayName: ws.TemplateDisplayName, - TemplateIcon: ws.TemplateIcon, - WorkspaceID: uuid.NullUUID{Valid: true, UUID: ws.ID}, - WorkspaceBuildNumber: ws.LatestBuild.BuildNumber, - WorkspaceAgentID: taskAgentID, - WorkspaceAgentLifecycle: taskAgentLifecycle, - WorkspaceAgentHealth: taskAgentHealth, - WorkspaceAppID: appID, - CreatedAt: ws.CreatedAt, - UpdatedAt: ws.UpdatedAt, - InitialPrompt: initialPrompt, - WorkspaceStatus: ws.LatestBuild.Status, - CurrentState: currentState, - } -} - -// tasksFromWorkspaces converts a slice of API workspaces into tasks, fetching -// prompts and mapping status/state. This method enforces that only AI task -// workspaces are given. -func (api *API) tasksFromWorkspaces(ctx context.Context, apiWorkspaces []codersdk.Workspace) ([]codersdk.Task, error) { - // Fetch prompts for each workspace build and map by build ID. - buildIDs := make([]uuid.UUID, 0, len(apiWorkspaces)) - for _, ws := range apiWorkspaces { - buildIDs = append(buildIDs, ws.LatestBuild.ID) - } - parameters, err := api.Database.GetWorkspaceBuildParametersByBuildIDs(ctx, buildIDs) - if err != nil { - return nil, err - } - promptsByBuildID := make(map[uuid.UUID]string, len(parameters)) - for _, p := range parameters { - if p.Name == codersdk.AITaskPromptParameterName { - promptsByBuildID[p.WorkspaceBuildID] = p.Value - } - } - - tasks := make([]codersdk.Task, 0, len(apiWorkspaces)) - for _, ws := range apiWorkspaces { - tasks = append(tasks, taskFromWorkspace(ws, promptsByBuildID[ws.LatestBuild.ID])) - } - - return tasks, nil -} - // tasksListResponse wraps a list of experimental tasks. // // Experimental: Response shape is experimental and may change. @@ -474,106 +373,41 @@ type tasksListResponse struct { // @ID list-tasks // @Security CoderSessionToken // @Tags Experimental -// @Param q query string false "Search query for filtering tasks" -// @Param after_id query string false "Return tasks after this ID for pagination" -// @Param limit query int false "Maximum number of tasks to return" minimum(1) maximum(100) default(25) -// @Param offset query int false "Offset for pagination" minimum(0) default(0) +// @Param q query string false "Search query for filtering tasks. Supports: owner:, organization:, status:" // @Success 200 {object} coderd.tasksListResponse // @Router /api/experimental/tasks [get] // // EXPERIMENTAL: This endpoint is experimental and not guaranteed to be stable. -// tasksList is an experimental endpoint to list AI tasks by mapping -// workspaces to a task-shaped response. +// tasksList is an experimental endpoint to list tasks. func (api *API) tasksList(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() apiKey := httpmw.APIKey(r) - // Support standard pagination/filters for workspaces. - page, ok := ParsePagination(rw, r) - if !ok { - return - } + // Parse query parameters for filtering tasks. queryStr := r.URL.Query().Get("q") - filter, errs := searchquery.Workspaces(ctx, api.Database, queryStr, page, api.AgentInactiveDisconnectTimeout) + filter, errs := searchquery.Tasks(ctx, api.Database, queryStr, apiKey.UserID) if len(errs) > 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Invalid workspace search query.", + Message: "Invalid task search query.", Validations: errs, }) return } - // Ensure that we only include AI task workspaces in the results. - filter.HasAITask = sql.NullBool{Valid: true, Bool: true} - - if filter.OwnerUsername == "me" { - filter.OwnerID = apiKey.UserID - filter.OwnerUsername = "" - } - - prepared, err := api.HTTPAuth.AuthorizeSQLFilter(r, policy.ActionRead, rbac.ResourceWorkspace.Type) + // Fetch all tasks matching the filters from the database. + dbTasks, err := api.Database.ListTasks(ctx, filter) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error preparing sql filter.", + Message: "Internal error fetching tasks.", Detail: err.Error(), }) return } - // Order with requester's favorites first, include summary row. - filter.RequesterID = apiKey.UserID - filter.WithSummary = true - - workspaceRows, err := api.Database.GetAuthorizedWorkspaces(ctx, filter, prepared) + tasks, err := api.convertTasks(ctx, apiKey.UserID, dbTasks) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching workspaces.", - Detail: err.Error(), - }) - return - } - if len(workspaceRows) == 0 { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching workspaces.", - Detail: "Workspace summary row is missing.", - }) - return - } - if len(workspaceRows) == 1 { - httpapi.Write(ctx, rw, http.StatusOK, tasksListResponse{ - Tasks: []codersdk.Task{}, - Count: 0, - }) - return - } - - // Skip summary row. - workspaceRows = workspaceRows[:len(workspaceRows)-1] - - workspaces := database.ConvertWorkspaceRows(workspaceRows) - - // Gather associated data and convert to API workspaces. - data, err := api.workspaceData(ctx, workspaces) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching workspace resources.", - Detail: err.Error(), - }) - return - } - apiWorkspaces, err := convertWorkspaces(apiKey.UserID, workspaces, data) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error converting workspaces.", - Detail: err.Error(), - }) - return - } - - tasks, err := api.tasksFromWorkspaces(ctx, apiWorkspaces) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching task prompts and states.", + Message: "Internal error converting tasks.", Detail: err.Error(), }) return @@ -585,6 +419,58 @@ func (api *API) tasksList(rw http.ResponseWriter, r *http.Request) { }) } +// convertTasks converts database tasks to API tasks, enriching them with +// workspace information. +func (api *API) convertTasks(ctx context.Context, requesterID uuid.UUID, dbTasks []database.Task) ([]codersdk.Task, error) { + if len(dbTasks) == 0 { + return []codersdk.Task{}, nil + } + + // Prepare to batch fetch workspaces. + workspaceIDs := make([]uuid.UUID, 0, len(dbTasks)) + for _, task := range dbTasks { + if !task.WorkspaceID.Valid { + return nil, xerrors.New("task has no workspace ID") + } + workspaceIDs = append(workspaceIDs, task.WorkspaceID.UUID) + } + + // Fetch workspaces for tasks that have workspaces. + workspaceRows, err := api.Database.GetWorkspaces(ctx, database.GetWorkspacesParams{ + WorkspaceIds: workspaceIDs, + }) + if err != nil { + return nil, xerrors.Errorf("fetch workspaces: %w", err) + } + + workspaces := database.ConvertWorkspaceRows(workspaceRows) + + // Gather associated data and convert to API workspaces. + data, err := api.workspaceData(ctx, workspaces) + if err != nil { + return nil, xerrors.Errorf("fetch workspace data: %w", err) + } + + apiWorkspaces, err := convertWorkspaces(requesterID, workspaces, data) + if err != nil { + return nil, xerrors.Errorf("convert workspaces: %w", err) + } + + workspacesByID := make(map[uuid.UUID]codersdk.Workspace) + for _, ws := range apiWorkspaces { + workspacesByID[ws.ID] = ws + } + + // Convert tasks to SDK format. + result := make([]codersdk.Task, 0, len(dbTasks)) + for _, dbTask := range dbTasks { + task := taskFromDBTaskAndWorkspace(dbTask, workspacesByID[dbTask.WorkspaceID.UUID]) + result = append(result, task) + } + + return result, nil +} + // @Summary Get AI task by ID // @Description: EXPERIMENTAL: this endpoint is experimental and not guaranteed to be stable. // @ID get-task diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go index c88785c858..10b33f4ab9 100644 --- a/coderd/aitasks_test.go +++ b/coderd/aitasks_test.go @@ -239,35 +239,38 @@ func TestTasks(t *testing.T) { t.Run("List", func(t *testing.T) { t.Parallel() - t.Skip("TODO(mafredri): Remove, fixed down-stack!") - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) user := coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) template := createAITemplate(t, client, user) - // Create a workspace (task) with a specific prompt. + // Create a task with a specific prompt using the new data model. wantPrompt := "build me a web app" - workspace := coderdtest.CreateWorkspace(t, client, template.ID, func(req *codersdk.CreateWorkspaceRequest) { - req.RichParameterValues = []codersdk.WorkspaceBuildParameter{ - {Name: codersdk.AITaskPromptParameterName, Value: wantPrompt}, - } + exp := codersdk.NewExperimentalClient(client) + task, err := exp.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{ + TemplateVersionID: template.ActiveVersionID, + Input: wantPrompt, }) + require.NoError(t, err) + require.True(t, task.WorkspaceID.Valid, "task should have a workspace ID") + + // Wait for the workspace to be built. + workspace, err := client.Workspace(ctx, task.WorkspaceID.UUID) + require.NoError(t, err) coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) // List tasks via experimental API and verify the prompt and status mapping. - exp := codersdk.NewExperimentalClient(client) tasks, err := exp.Tasks(ctx, &codersdk.TasksFilter{Owner: codersdk.Me}) require.NoError(t, err) - got, ok := slice.Find(tasks, func(task codersdk.Task) bool { return task.ID == workspace.ID }) + got, ok := slice.Find(tasks, func(t codersdk.Task) bool { return t.ID == task.ID }) require.True(t, ok, "task should be found in the list") assert.Equal(t, wantPrompt, got.InitialPrompt, "task prompt should match the AI Prompt parameter") - assert.Equal(t, workspace.Name, got.Name, "task name should map from workspace name") - assert.Equal(t, workspace.ID, got.WorkspaceID.UUID, "workspace id should match") - // Status should be populated via app status or workspace status mapping. - assert.NotEmpty(t, got.WorkspaceStatus, "task status should not be empty") + assert.Equal(t, task.WorkspaceID.UUID, got.WorkspaceID.UUID, "workspace id should match") + // Status should be populated via the tasks_with_status view. + assert.NotEmpty(t, got.Status, "task status should not be empty") + assert.NotEmpty(t, got.WorkspaceStatus, "workspace status should not be empty") }) t.Run("Get", func(t *testing.T) { diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index d6ba1d081d..669c753b24 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -151,32 +151,9 @@ const docTemplate = `{ "parameters": [ { "type": "string", - "description": "Search query for filtering tasks", + "description": "Search query for filtering tasks. Supports: owner:\u003cusername/uuid/me\u003e, organization:\u003corg-name/uuid\u003e, status:\u003cstatus\u003e", "name": "q", "in": "query" - }, - { - "type": "string", - "description": "Return tasks after this ID for pagination", - "name": "after_id", - "in": "query" - }, - { - "maximum": 100, - "minimum": 1, - "type": "integer", - "default": 25, - "description": "Maximum number of tasks to return", - "name": "limit", - "in": "query" - }, - { - "minimum": 0, - "type": "integer", - "default": 0, - "description": "Offset for pagination", - "name": "offset", - "in": "query" } ], "responses": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index bf363ba78b..ba019ba82d 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -125,32 +125,9 @@ "parameters": [ { "type": "string", - "description": "Search query for filtering tasks", + "description": "Search query for filtering tasks. Supports: owner:\u003cusername/uuid/me\u003e, organization:\u003corg-name/uuid\u003e, status:\u003cstatus\u003e", "name": "q", "in": "query" - }, - { - "type": "string", - "description": "Return tasks after this ID for pagination", - "name": "after_id", - "in": "query" - }, - { - "maximum": 100, - "minimum": 1, - "type": "integer", - "default": 25, - "description": "Maximum number of tasks to return", - "name": "limit", - "in": "query" - }, - { - "minimum": 0, - "type": "integer", - "default": 0, - "description": "Offset for pagination", - "name": "offset", - "in": "query" } ], "responses": { diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index 1479dfb198..9eaf1afb93 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -120,19 +120,23 @@ func (b WorkspaceBuildBuilder) WithAgent(mutations ...func([]*sdkproto.Agent) [] } func (b WorkspaceBuildBuilder) WithTask(seed *sdkproto.App) WorkspaceBuildBuilder { - //nolint: revive // returns modified struct - b.taskAppID = uuid.New() if seed == nil { seed = &sdkproto.App{} } + + var err error + //nolint: revive // returns modified struct + b.taskAppID, err = uuid.Parse(takeFirst(seed.Id, uuid.NewString())) + require.NoError(b.t, err) + return b.Params(database.WorkspaceBuildParameter{ Name: codersdk.AITaskPromptParameterName, Value: "list me", }).WithAgent(func(a []*sdkproto.Agent) []*sdkproto.Agent { a[0].Apps = []*sdkproto.App{ { - Id: takeFirst(seed.Id, b.taskAppID.String()), - Slug: takeFirst(seed.Slug, "vcode"), + Id: b.taskAppID.String(), + Slug: takeFirst(seed.Slug, "task-app"), Url: takeFirst(seed.Url, ""), }, } @@ -195,11 +199,11 @@ func (b WorkspaceBuildBuilder) Do() WorkspaceResponse { if b.ws.ID == uuid.Nil { // nolint: revive b.ws = dbgen.Workspace(b.t, b.db, b.ws) - resp.Workspace = b.ws b.logger.Debug(context.Background(), "created workspace", - slog.F("name", resp.Workspace.Name), - slog.F("workspace_id", resp.Workspace.ID)) + slog.F("name", b.ws.Name), + slog.F("workspace_id", b.ws.ID)) } + resp.Workspace = b.ws b.seed.WorkspaceID = b.ws.ID b.seed.InitiatorID = takeFirst(b.seed.InitiatorID, b.ws.OwnerID) @@ -273,6 +277,30 @@ func (b WorkspaceBuildBuilder) Do() WorkspaceResponse { slog.F("workspace_id", resp.Workspace.ID), slog.F("build_number", resp.Build.BuildNumber)) + // If this is a task workspace, link it to the workspace build. + task, err := b.db.GetTaskByWorkspaceID(ownerCtx, resp.Workspace.ID) + if err != nil { + if b.taskAppID != uuid.Nil { + require.Fail(b.t, "task app configured but failed to get task by workspace id", err) + } + } else { + if b.taskAppID == uuid.Nil { + require.Fail(b.t, "task app not configured but workspace is a task workspace") + } + + app := mustWorkspaceAppByWorkspaceAndBuildAndAppID(ownerCtx, b.t, b.db, resp.Workspace.ID, resp.Build.BuildNumber, b.taskAppID) + _, err = b.db.UpsertTaskWorkspaceApp(ownerCtx, database.UpsertTaskWorkspaceAppParams{ + TaskID: task.ID, + WorkspaceBuildNumber: resp.Build.BuildNumber, + WorkspaceAgentID: uuid.NullUUID{UUID: app.AgentID, Valid: true}, + WorkspaceAppID: uuid.NullUUID{UUID: app.ID, Valid: true}, + }) + require.NoError(b.t, err, "upsert task workspace app") + b.logger.Debug(context.Background(), "linked task to workspace build", + slog.F("task_id", task.ID), + slog.F("build_number", resp.Build.BuildNumber)) + } + for i := range b.params { b.params[i].WorkspaceBuildID = resp.Build.ID } @@ -623,3 +651,30 @@ func takeFirst[Value comparable](values ...Value) Value { return v != empty }) } + +// mustWorkspaceAppByWorkspaceAndBuildAndAppID finds a workspace app by +// workspace ID, build number, and app ID. It returns the workspace app +// if found, otherwise fails the test. +func mustWorkspaceAppByWorkspaceAndBuildAndAppID(ctx context.Context, t testing.TB, db database.Store, workspaceID uuid.UUID, buildNumber int32, appID uuid.UUID) database.WorkspaceApp { + t.Helper() + + agents, err := db.GetWorkspaceAgentsByWorkspaceAndBuildNumber(ctx, database.GetWorkspaceAgentsByWorkspaceAndBuildNumberParams{ + WorkspaceID: workspaceID, + BuildNumber: buildNumber, + }) + require.NoError(t, err, "get workspace agents") + require.NotEmpty(t, agents, "no agents found for workspace") + + for _, agent := range agents { + apps, err := db.GetWorkspaceAppsByAgentID(ctx, agent.ID) + require.NoError(t, err, "get workspace apps") + for _, app := range apps { + if app.ID == appID { + return app + } + } + } + + require.FailNow(t, "could not find workspace app", "workspaceID=%s buildNumber=%d appID=%s", workspaceID, buildNumber, appID) + return database.WorkspaceApp{} // Unreachable. +} diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 55bb5953d3..d257d571c0 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -12717,16 +12717,18 @@ SELECT id, organization_id, owner_id, name, workspace_id, template_version_id, t WHERE tws.deleted_at IS NULL AND CASE WHEN $1::UUID != '00000000-0000-0000-0000-000000000000' THEN tws.owner_id = $1::UUID ELSE TRUE END AND CASE WHEN $2::UUID != '00000000-0000-0000-0000-000000000000' THEN tws.organization_id = $2::UUID ELSE TRUE END +AND CASE WHEN $3::text != '' THEN tws.status = $3::task_status ELSE TRUE END ORDER BY tws.created_at DESC ` type ListTasksParams struct { OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + Status string `db:"status" json:"status"` } func (q *sqlQuerier) ListTasks(ctx context.Context, arg ListTasksParams) ([]Task, error) { - rows, err := q.db.QueryContext(ctx, listTasks, arg.OwnerID, arg.OrganizationID) + rows, err := q.db.QueryContext(ctx, listTasks, arg.OwnerID, arg.OrganizationID, arg.Status) if err != nil { return nil, err } diff --git a/coderd/database/queries/tasks.sql b/coderd/database/queries/tasks.sql index 9e83a9f87a..6c076b8cca 100644 --- a/coderd/database/queries/tasks.sql +++ b/coderd/database/queries/tasks.sql @@ -46,6 +46,7 @@ SELECT * FROM tasks_with_status tws WHERE tws.deleted_at IS NULL AND CASE WHEN @owner_id::UUID != '00000000-0000-0000-0000-000000000000' THEN tws.owner_id = @owner_id::UUID ELSE TRUE END AND CASE WHEN @organization_id::UUID != '00000000-0000-0000-0000-000000000000' THEN tws.organization_id = @organization_id::UUID ELSE TRUE END +AND CASE WHEN @status::text != '' THEN tws.status = @status::task_status ELSE TRUE END ORDER BY tws.created_at DESC; -- name: DeleteTask :one diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index 3b34edacef..59ec3e0492 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -391,6 +391,43 @@ func AIBridgeInterceptions(ctx context.Context, db database.Store, query string, return filter, parser.Errors } +// Tasks parses a search query for tasks. +// +// Supported query parameters: +// - owner: string (username, UUID, or 'me' for current user) +// - organization: string (organization UUID or name) +// - status: string (pending, initializing, active, paused, error, unknown) +func Tasks(ctx context.Context, db database.Store, query string, actorID uuid.UUID) (database.ListTasksParams, []codersdk.ValidationError) { + filter := database.ListTasksParams{ + OwnerID: uuid.Nil, + OrganizationID: uuid.Nil, + Status: "", + } + + if query == "" { + return filter, nil + } + + // Always lowercase for all searches. + query = strings.ToLower(query) + values, errors := searchTerms(query, func(term string, values url.Values) error { + // Default unqualified terms to owner + values.Add("owner", term) + return nil + }) + if len(errors) > 0 { + return filter, errors + } + + parser := httpapi.NewQueryParamParser() + filter.OwnerID = parseUser(ctx, db, parser, values, "owner", actorID) + filter.OrganizationID = parseOrganization(ctx, db, parser, values, "organization") + filter.Status = parser.String(values, "", "status") + + parser.ErrorExcessParams(values) + return filter, parser.Errors +} + func searchTerms(query string, defaultKey func(term string, values url.Values) error) (url.Values, []codersdk.ValidationError) { searchValues := make(url.Values) diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index 84d4509d0e..44ae9d1021 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -944,3 +944,199 @@ func TestSearchTemplates(t *testing.T) { }) } } + +func TestSearchTasks(t *testing.T) { + t.Parallel() + + userID := uuid.MustParse("10000000-0000-0000-0000-000000000001") + orgID := uuid.MustParse("20000000-0000-0000-0000-000000000001") + + testCases := []struct { + Name string + Query string + ActorID uuid.UUID + Expected database.ListTasksParams + ExpectedErrorContains string + Setup func(t *testing.T, db database.Store) + }{ + { + Name: "Empty", + Query: "", + Expected: database.ListTasksParams{}, + }, + { + Name: "OwnerUsername", + Query: "owner:alice", + Setup: func(t *testing.T, db database.Store) { + dbgen.User(t, db, database.User{ + ID: userID, + Username: "alice", + }) + }, + Expected: database.ListTasksParams{ + OwnerID: userID, + }, + }, + { + Name: "OwnerMe", + Query: "owner:me", + ActorID: userID, + Expected: database.ListTasksParams{ + OwnerID: userID, + }, + }, + { + Name: "OwnerUUID", + Query: fmt.Sprintf("owner:%s", userID), + Expected: database.ListTasksParams{ + OwnerID: userID, + }, + }, + { + Name: "StatusActive", + Query: "status:active", + Expected: database.ListTasksParams{ + Status: "active", + }, + }, + { + Name: "StatusPending", + Query: "status:pending", + Expected: database.ListTasksParams{ + Status: "pending", + }, + }, + { + Name: "Organization", + Query: "organization:acme", + Setup: func(t *testing.T, db database.Store) { + dbgen.Organization(t, db, database.Organization{ + ID: orgID, + Name: "acme", + }) + }, + Expected: database.ListTasksParams{ + OrganizationID: orgID, + }, + }, + { + Name: "OrganizationUUID", + Query: fmt.Sprintf("organization:%s", orgID), + Expected: database.ListTasksParams{ + OrganizationID: orgID, + }, + }, + { + Name: "Combined", + Query: "owner:alice organization:acme status:active", + Setup: func(t *testing.T, db database.Store) { + dbgen.Organization(t, db, database.Organization{ + ID: orgID, + Name: "acme", + }) + dbgen.User(t, db, database.User{ + ID: userID, + Username: "alice", + }) + }, + Expected: database.ListTasksParams{ + OwnerID: userID, + OrganizationID: orgID, + Status: "active", + }, + }, + { + Name: "QuotedOwner", + Query: `owner:"alice"`, + Setup: func(t *testing.T, db database.Store) { + dbgen.User(t, db, database.User{ + ID: userID, + Username: "alice", + }) + }, + Expected: database.ListTasksParams{ + OwnerID: userID, + }, + }, + { + Name: "QuotedStatus", + Query: `status:"pending"`, + Expected: database.ListTasksParams{ + Status: "pending", + }, + }, + { + Name: "DefaultToOwner", + Query: "alice", + Setup: func(t *testing.T, db database.Store) { + dbgen.User(t, db, database.User{ + ID: userID, + Username: "alice", + }) + }, + Expected: database.ListTasksParams{ + OwnerID: userID, + }, + }, + { + Name: "InvalidOwner", + Query: "owner:nonexistent", + ExpectedErrorContains: "does not exist", + }, + { + Name: "InvalidOrganization", + Query: "organization:nonexistent", + ExpectedErrorContains: "does not exist", + }, + { + Name: "ExtraParam", + Query: "owner:alice invalid:param", + Setup: func(t *testing.T, db database.Store) { + dbgen.User(t, db, database.User{ + ID: userID, + Username: "alice", + }) + }, + ExpectedErrorContains: "is not a valid query param", + }, + { + Name: "ExtraColon", + Query: "owner:alice:extra", + ExpectedErrorContains: "can only contain 1 ':'", + }, + { + Name: "PrefixColon", + Query: ":owner", + ExpectedErrorContains: "cannot start or end with ':'", + }, + { + Name: "SuffixColon", + Query: "owner:", + ExpectedErrorContains: "cannot start or end with ':'", + }, + } + + for _, c := range testCases { + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + + if c.Setup != nil { + c.Setup(t, db) + } + + values, errs := searchquery.Tasks(context.Background(), db, c.Query, c.ActorID) + if c.ExpectedErrorContains != "" { + require.True(t, len(errs) > 0, "expect some errors") + var s strings.Builder + for _, err := range errs { + _, _ = s.WriteString(fmt.Sprintf("%s: %s\n", err.Field, err.Detail)) + } + require.Contains(t, s.String(), c.ExpectedErrorContains) + } else { + require.Len(t, errs, 0, "expected no error") + require.Equal(t, c.Expected, values, "expected values") + } + }) + } +} diff --git a/coderd/telemetry/telemetry.go b/coderd/telemetry/telemetry.go index 37e41fcd70..1526f51f16 100644 --- a/coderd/telemetry/telemetry.go +++ b/coderd/telemetry/telemetry.go @@ -734,6 +734,7 @@ func (r *remoteReporter) createSnapshot() (*Snapshot, error) { dbTasks, err := r.options.Database.ListTasks(ctx, database.ListTasksParams{ OwnerID: uuid.Nil, OrganizationID: uuid.Nil, + Status: "", }) if err != nil { return err diff --git a/codersdk/aitasks.go b/codersdk/aitasks.go index 8232d80dc4..b5c7fa5aa9 100644 --- a/codersdk/aitasks.go +++ b/codersdk/aitasks.go @@ -103,6 +103,17 @@ const ( TaskStatusError TaskStatus = "error" ) +func AllTaskStatuses() []TaskStatus { + return []TaskStatus{ + TaskStatusPending, + TaskStatusInitializing, + TaskStatusActive, + TaskStatusPaused, + TaskStatusError, + TaskStatusUnknown, + } +} + // TaskState represents the high-level lifecycle of a task. // // Experimental: This type is experimental and may change in the future. @@ -160,18 +171,37 @@ type TaskStateEntry struct { type TasksFilter struct { // Owner can be a username, UUID, or "me". Owner string `json:"owner,omitempty"` - // Status filters the tasks by their status. - // - // TODO(mafredri): Enable this field. - // Status TaskStatus `json:"status,omitempty"` - // WorkspaceStatus is a workspace status to filter by. - // - // Deprecated: Use TaskStatus instead. - WorkspaceStatus string `json:"workspace_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:"-"` + // Organization can be an organization name or UUID. + Organization string `json:"organization,omitempty"` + // Status filters the tasks by their task status. + Status TaskStatus `json:"status,omitempty"` + // FilterQuery allows specifying a raw filter query. + FilterQuery string `json:"filter_query,omitempty"` +} + +func (f TasksFilter) asRequestOption() RequestOption { + return func(r *http.Request) { + var params []string + // Make sure all user input is quoted to ensure it's parsed as a single + // string. + if f.Owner != "" { + params = append(params, fmt.Sprintf("owner:%q", f.Owner)) + } + if f.Organization != "" { + params = append(params, fmt.Sprintf("organization:%q", f.Organization)) + } + if f.Status != "" { + params = append(params, fmt.Sprintf("status:%q", string(f.Status))) + } + if f.FilterQuery != "" { + // If custom stuff is added, just add it on here. + params = append(params, f.FilterQuery) + } + + q := r.URL.Query() + q.Set("q", strings.Join(params, " ")) + r.URL.RawQuery = q.Encode() + } } // Tasks lists all tasks belonging to the user or specified owner. @@ -182,15 +212,7 @@ func (c *ExperimentalClient) Tasks(ctx context.Context, filter *TasksFilter) ([] filter = &TasksFilter{} } - var wsFilter WorkspaceFilter - wsFilter.Owner = filter.Owner - wsFilter.Status = filter.WorkspaceStatus - page := Pagination{ - Offset: filter.Offset, - Limit: filter.Limit, - } - - res, err := c.Request(ctx, http.MethodGet, "/api/experimental/tasks", nil, wsFilter.asRequestOption(), page.asRequestOption()) + res, err := c.Request(ctx, http.MethodGet, "/api/experimental/tasks", nil, filter.asRequestOption()) if err != nil { return nil, err } @@ -233,6 +255,72 @@ func (c *ExperimentalClient) TaskByID(ctx context.Context, id uuid.UUID) (Task, return task, nil } +func splitTaskIdentifier(identifier string) (owner string, taskName string, err error) { + parts := strings.Split(identifier, "/") + + switch len(parts) { + case 1: + owner = Me + taskName = parts[0] + case 2: + owner = parts[0] + taskName = parts[1] + default: + return "", "", xerrors.Errorf("invalid task identifier: %q", identifier) + } + return owner, taskName, nil +} + +// TaskByIdentifier fetches and returns a task by an identifier, which may be +// either a UUID, a name (for a task owned by the current user), or a +// "user/task" combination, where user is either a username or UUID. +// +// Since there is no TaskByOwnerAndName endpoint yet, this function uses the +// list endpoint with filtering when a name is provided. +func (c *ExperimentalClient) TaskByIdentifier(ctx context.Context, identifier string) (Task, error) { + identifier = strings.TrimSpace(identifier) + + // Try parsing as UUID first. + if taskID, err := uuid.Parse(identifier); err == nil { + return c.TaskByID(ctx, taskID) + } + + // Not a UUID, treat as identifier. + owner, taskName, err := splitTaskIdentifier(identifier) + if err != nil { + return Task{}, err + } + + tasks, err := c.Tasks(ctx, &TasksFilter{ + Owner: owner, + }) + if err != nil { + return Task{}, xerrors.Errorf("list tasks for owner %q: %w", owner, err) + } + + if taskID, err := uuid.Parse(taskName); err == nil { + // Find task by ID. + for _, task := range tasks { + if task.ID == taskID { + return task, nil + } + } + } else { + // Find task by name. + for _, task := range tasks { + if task.Name == taskName { + return task, nil + } + } + } + + // Mimic resource not found from API. + var notFoundErr error = &Error{ + Response: Response{Message: "Resource not found or you do not have access to this resource"}, + } + return Task{}, xerrors.Errorf("task %q not found for owner %q: %w", taskName, owner, notFoundErr) +} + // DeleteTask deletes a task by its ID. // // Experimental: This method is experimental and may change in the future. diff --git a/codersdk/aitasks_internal_test.go b/codersdk/aitasks_internal_test.go new file mode 100644 index 0000000000..b10a8659a6 --- /dev/null +++ b/codersdk/aitasks_internal_test.go @@ -0,0 +1,75 @@ +package codersdk + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_splitTaskIdentifier(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + identifier string + expectedOwner string + expectedTask string + expectErr bool + }{ + { + name: "bare task name", + identifier: "mytask", + expectedOwner: Me, + expectedTask: "mytask", + expectErr: false, + }, + { + name: "owner/task format", + identifier: "alice/her-task", + expectedOwner: "alice", + expectedTask: "her-task", + expectErr: false, + }, + { + name: "uuid/task format", + identifier: "550e8400-e29b-41d4-a716-446655440000/task1", + expectedOwner: "550e8400-e29b-41d4-a716-446655440000", + expectedTask: "task1", + expectErr: false, + }, + { + name: "owner/uuid format", + identifier: "alice/3abe1dcf-cd87-4078-8b54-c0e2058ad2e2", + expectedOwner: "alice", + expectedTask: "3abe1dcf-cd87-4078-8b54-c0e2058ad2e2", + expectErr: false, + }, + { + name: "too many slashes", + identifier: "owner/task/extra", + expectErr: true, + }, + { + name: "empty parts acceptable", + identifier: "/task", + expectedOwner: "", + expectedTask: "task", + expectErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + owner, taskName, err := splitTaskIdentifier(tt.identifier) + if tt.expectErr { + require.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tt.expectedOwner, owner) + assert.Equal(t, tt.expectedTask, taskName) + } + }) + } +} diff --git a/codersdk/toolsdk/bash.go b/codersdk/toolsdk/bash.go index 7497363c2a..55b3dd58bc 100644 --- a/codersdk/toolsdk/bash.go +++ b/codersdk/toolsdk/bash.go @@ -266,20 +266,25 @@ func getWorkspaceAgent(workspace codersdk.Workspace, agentName string) (codersdk return codersdk.WorkspaceAgent{}, xerrors.Errorf("multiple agents found, please specify the agent name, available agents: %v", availableNames) } -// namedWorkspace gets a workspace by owner/name or just name -func namedWorkspace(ctx context.Context, client *codersdk.Client, identifier string) (codersdk.Workspace, error) { - // Parse owner and workspace name +func splitNameAndOwner(identifier string) (name string, owner string) { + // Parse owner and name (workspace, task). parts := strings.SplitN(identifier, "/", 2) - var owner, workspaceName string if len(parts) == 2 { owner = parts[0] - workspaceName = parts[1] + name = parts[1] } else { owner = "me" - workspaceName = identifier + name = identifier } + return name, owner +} + +// namedWorkspace gets a workspace by owner/name or just name +func namedWorkspace(ctx context.Context, client *codersdk.Client, identifier string) (codersdk.Workspace, error) { + workspaceName, owner := splitNameAndOwner(identifier) + // Handle -- separator format (convert to / format) if strings.Contains(identifier, "--") && !strings.Contains(identifier, "/") { dashParts := strings.SplitN(identifier, "--", 2) diff --git a/codersdk/toolsdk/toolsdk.go b/codersdk/toolsdk/toolsdk.go index e82511b46b..43ed6ab98a 100644 --- a/codersdk/toolsdk/toolsdk.go +++ b/codersdk/toolsdk/toolsdk.go @@ -1909,24 +1909,12 @@ var DeleteTask = Tool[DeleteTaskArgs, codersdk.Response]{ expClient := codersdk.NewExperimentalClient(deps.coderClient) - var owner string - id, err := uuid.Parse(args.TaskID) - if err == nil { - task, err := expClient.TaskByID(ctx, id) - if err != nil { - return codersdk.Response{}, xerrors.Errorf("get task %q: %w", args.TaskID, err) - } - owner = task.OwnerName - } else { - ws, err := normalizedNamedWorkspace(ctx, deps.coderClient, args.TaskID) - if err != nil { - return codersdk.Response{}, xerrors.Errorf("get task workspace %q: %w", args.TaskID, err) - } - owner = ws.OwnerName - id = ws.ID + task, err := expClient.TaskByIdentifier(ctx, args.TaskID) + if err != nil { + return codersdk.Response{}, xerrors.Errorf("resolve task: %w", err) } - err = expClient.DeleteTask(ctx, owner, id) + err = expClient.DeleteTask(ctx, task.OwnerName, task.ID) if err != nil { return codersdk.Response{}, xerrors.Errorf("delete task: %w", err) } @@ -1938,8 +1926,8 @@ var DeleteTask = Tool[DeleteTaskArgs, codersdk.Response]{ } type ListTasksArgs struct { - WorkspaceStatus string `json:"status"` - User string `json:"user"` + Status codersdk.TaskStatus `json:"status"` + User string `json:"user"` } type ListTasksResponse struct { @@ -1972,8 +1960,8 @@ var ListTasks = Tool[ListTasksArgs, ListTasksResponse]{ expClient := codersdk.NewExperimentalClient(deps.coderClient) tasks, err := expClient.Tasks(ctx, &codersdk.TasksFilter{ - Owner: args.User, - WorkspaceStatus: args.WorkspaceStatus, + Owner: args.User, + Status: args.Status, }) if err != nil { return ListTasksResponse{}, xerrors.Errorf("list tasks: %w", err) @@ -1990,7 +1978,7 @@ type GetTaskStatusArgs struct { } type GetTaskStatusResponse struct { - Status codersdk.WorkspaceStatus `json:"status"` + Status codersdk.TaskStatus `json:"status"` State *codersdk.TaskStateEntry `json:"state"` } @@ -2016,22 +2004,13 @@ var GetTaskStatus = Tool[GetTaskStatusArgs, GetTaskStatusResponse]{ expClient := codersdk.NewExperimentalClient(deps.coderClient) - id, err := uuid.Parse(args.TaskID) + task, err := expClient.TaskByIdentifier(ctx, args.TaskID) if err != nil { - ws, err := normalizedNamedWorkspace(ctx, deps.coderClient, args.TaskID) - if err != nil { - return GetTaskStatusResponse{}, xerrors.Errorf("get task workspace %q: %w", args.TaskID, err) - } - id = ws.ID - } - - task, err := expClient.TaskByID(ctx, id) - if err != nil { - return GetTaskStatusResponse{}, xerrors.Errorf("get task %q: %w", args.TaskID, err) + return GetTaskStatusResponse{}, xerrors.Errorf("resolve task %q: %w", args.TaskID, err) } return GetTaskStatusResponse{ - Status: task.WorkspaceStatus, + Status: task.Status, State: task.CurrentState, }, nil }, @@ -2071,12 +2050,13 @@ var SendTaskInput = Tool[SendTaskInputArgs, codersdk.Response]{ } expClient := codersdk.NewExperimentalClient(deps.coderClient) - id, owner, err := resolveTaskID(ctx, deps.coderClient, args.TaskID) + + task, err := expClient.TaskByIdentifier(ctx, args.TaskID) if err != nil { - return codersdk.Response{}, err + return codersdk.Response{}, xerrors.Errorf("resolve task %q: %w", args.TaskID, err) } - err = expClient.TaskSend(ctx, owner, id, codersdk.TaskSendRequest{ + err = expClient.TaskSend(ctx, task.OwnerName, task.ID, codersdk.TaskSendRequest{ Input: args.Input, }) if err != nil { @@ -2114,12 +2094,13 @@ var GetTaskLogs = Tool[GetTaskLogsArgs, codersdk.TaskLogsResponse]{ } expClient := codersdk.NewExperimentalClient(deps.coderClient) - id, owner, err := resolveTaskID(ctx, deps.coderClient, args.TaskID) + + task, err := expClient.TaskByIdentifier(ctx, args.TaskID) if err != nil { return codersdk.TaskLogsResponse{}, err } - logs, err := expClient.TaskLogs(ctx, owner, id) + logs, err := expClient.TaskLogs(ctx, task.OwnerName, task.ID) if err != nil { return codersdk.TaskLogsResponse{}, xerrors.Errorf("get task logs %q: %w", args.TaskID, err) } @@ -2128,13 +2109,6 @@ var GetTaskLogs = Tool[GetTaskLogsArgs, codersdk.TaskLogsResponse]{ }, } -// normalizedNamedWorkspace normalizes the workspace name before getting the -// workspace by name. -func normalizedNamedWorkspace(ctx context.Context, client *codersdk.Client, name string) (codersdk.Workspace, error) { - // Maybe namedWorkspace should itself call NormalizeWorkspaceInput? - return namedWorkspace(ctx, client, NormalizeWorkspaceInput(name)) -} - // NormalizeWorkspaceInput converts workspace name input to standard format. // Handles the following input formats: // - workspace → workspace @@ -2205,15 +2179,3 @@ func taskIDDescription(action string) string { func userDescription(action string) string { return fmt.Sprintf("Username or ID of the user for which to %s. Omit or use the `me` keyword to %s for the authenticated user.", action, action) } - -func resolveTaskID(ctx context.Context, coderClient *codersdk.Client, taskID string) (uuid.UUID, string, error) { - id, err := uuid.Parse(taskID) - if err == nil { - return id, codersdk.Me, nil - } - ws, err := normalizedNamedWorkspace(ctx, coderClient, taskID) - if err != nil { - return uuid.UUID{}, codersdk.Me, xerrors.Errorf("get task workspace %q: %w", taskID, err) - } - return ws.ID, ws.OwnerName, nil -} diff --git a/codersdk/toolsdk/toolsdk_test.go b/codersdk/toolsdk/toolsdk_test.go index 973ffea109..44da500400 100644 --- a/codersdk/toolsdk/toolsdk_test.go +++ b/codersdk/toolsdk/toolsdk_test.go @@ -35,6 +35,7 @@ import ( "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/coder/v2/codersdk/toolsdk" "github.com/coder/coder/v2/codersdk/workspacesdk" + "github.com/coder/coder/v2/provisioner/echo" "github.com/coder/coder/v2/provisionersdk/proto" "github.com/coder/coder/v2/testutil" ) @@ -881,11 +882,9 @@ func TestTools(t *testing.T) { } }) - t.Run("WorkspaceDeleteTask", func(t *testing.T) { + t.Run("DeleteTask", func(t *testing.T) { t.Parallel() - t.Skip("TODO(mafredri): Remove, fixed down-stack!") - // nolint:gocritic // This is in a test package and does not end up in the build aiTV := dbfake.TemplateVersion(t, store).Seed(database.TemplateVersion{ OrganizationID: owner.OrganizationID, @@ -896,21 +895,37 @@ func TestTools(t *testing.T) { }, }).Do() - // nolint:gocritic // This is in a test package and does not end up in the build - ws1 := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + ws1Table := dbgen.Workspace(t, store, database.WorkspaceTable{ Name: "delete-task-workspace-1", OrganizationID: owner.OrganizationID, OwnerID: member.ID, TemplateID: aiTV.Template.ID, - }).WithTask(nil).Do() + }) + task1 := dbgen.Task(t, store, database.TaskTable{ + OrganizationID: owner.OrganizationID, + OwnerID: member.ID, + Name: ws1Table.Name, + WorkspaceID: uuid.NullUUID{UUID: ws1Table.ID, Valid: true}, + TemplateVersionID: aiTV.TemplateVersion.ID, + Prompt: "delete task 1", + }) + _ = dbfake.WorkspaceBuild(t, store, ws1Table).WithTask(nil).Do() - // nolint:gocritic // This is in a test package and does not end up in the build - _ = dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + ws2Table := dbgen.Workspace(t, store, database.WorkspaceTable{ Name: "delete-task-workspace-2", OrganizationID: owner.OrganizationID, OwnerID: member.ID, TemplateID: aiTV.Template.ID, - }).WithTask(nil).Do() + }) + task2 := dbgen.Task(t, store, database.TaskTable{ + OrganizationID: owner.OrganizationID, + OwnerID: member.ID, + Name: ws2Table.Name, + WorkspaceID: uuid.NullUUID{UUID: ws2Table.ID, Valid: true}, + TemplateVersionID: aiTV.TemplateVersion.ID, + Prompt: "delete task 2", + }) + _ = dbfake.WorkspaceBuild(t, store, ws2Table).WithTask(nil).Do() tests := []struct { name string @@ -920,13 +935,13 @@ func TestTools(t *testing.T) { { name: "ByUUID", args: toolsdk.DeleteTaskArgs{ - TaskID: ws1.Workspace.ID.String(), + TaskID: task1.ID.String(), }, }, { - name: "ByWorkspaceIdentifier", + name: "ByIdentifier", args: toolsdk.DeleteTaskArgs{ - TaskID: "delete-task-workspace-2", + TaskID: task2.Name, }, }, { @@ -975,47 +990,64 @@ func TestTools(t *testing.T) { } }) - t.Run("WorkspaceListTasks", func(t *testing.T) { + t.Run("ListTasks", func(t *testing.T) { t.Parallel() - t.Skip("TODO(mafredri): Remove, fixed down-stack!") + ctx := testutil.Context(t, testutil.WaitLong) + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + _, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + taskClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) - taskClient, taskUser := coderdtest.CreateAnotherUserMutators(t, client, owner.OrganizationID, nil) - - // nolint:gocritic // This is in a test package and does not end up in the build - aiTV := dbfake.TemplateVersion(t, store).Seed(database.TemplateVersion{ - OrganizationID: owner.OrganizationID, - CreatedBy: owner.UserID, - HasAITask: sql.NullBool{ - Bool: true, - Valid: true, + // Create a template with AI task support using the proper flow. + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionApply: echo.ApplyComplete, + ProvisionPlan: []*proto.Response{ + {Type: &proto.Response_Plan{Plan: &proto.PlanComplete{ + Parameters: []*proto.RichParameter{{Name: "AI Prompt", Type: "string"}}, + HasAiTasks: true, + }}}, }, - }).Do() + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + + expClient := codersdk.NewExperimentalClient(client) + taskExpClient := codersdk.NewExperimentalClient(taskClient) // This task should not show up since listing is user-scoped. - // nolint:gocritic // This is in a test package and does not end up in the build - _ = dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ - Name: "list-task-workspace-member", - OrganizationID: owner.OrganizationID, - OwnerID: member.ID, - TemplateID: aiTV.Template.ID, - }).WithTask(nil).Do() + _, err := expClient.CreateTask(ctx, member.Username, codersdk.CreateTaskRequest{ + TemplateVersionID: template.ActiveVersionID, + Input: "task for member", + Name: "list-task-workspace-member", + }) + require.NoError(t, err) - // These tasks should show up. + // Create tasks for taskUser. These should show up in the list. for i := range 5 { - // nolint:gocritic // This is in a test package and does not end up in the build - var transition database.WorkspaceTransition + taskName := fmt.Sprintf("list-task-workspace-%d", i) + task, err := taskExpClient.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{ + TemplateVersionID: template.ActiveVersionID, + Input: fmt.Sprintf("task %d", i), + Name: taskName, + }) + require.NoError(t, err) + require.True(t, task.WorkspaceID.Valid, "task should have workspace ID") + + // For the first task, stop the workspace to make it paused. if i == 0 { - // nolint:gocritic // This is in a test package and does not end up in the build - transition = database.WorkspaceTransitionStop + ws, err := taskClient.Workspace(ctx, task.WorkspaceID.UUID) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, taskClient, ws.LatestBuild.ID) + + // Stop the workspace to set task status to paused. + build, err := taskClient.CreateWorkspaceBuild(ctx, task.WorkspaceID.UUID, codersdk.CreateWorkspaceBuildRequest{ + Transition: codersdk.WorkspaceTransitionStop, + }) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, taskClient, build.ID) } - // nolint:gocritic // This is in a test package and does not end up in the build - _ = dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ - Name: fmt.Sprintf("list-task-workspace-%d", i), - OrganizationID: owner.OrganizationID, - OwnerID: taskUser.ID, - TemplateID: aiTV.Template.ID, - }).Seed(database.WorkspaceBuild{Transition: transition}).WithTask(nil).Do() } tests := []struct { @@ -1038,7 +1070,7 @@ func TestTools(t *testing.T) { { name: "ListFiltered", args: toolsdk.ListTasksArgs{ - WorkspaceStatus: "stopped", + Status: codersdk.TaskStatusPaused, }, expected: []string{ "list-task-workspace-0", @@ -1068,11 +1100,9 @@ func TestTools(t *testing.T) { } }) - t.Run("WorkspaceGetTask", func(t *testing.T) { + t.Run("GetTask", func(t *testing.T) { t.Parallel() - t.Skip("TODO(mafredri): Remove, fixed down-stack!") - // nolint:gocritic // This is in a test package and does not end up in the build aiTV := dbfake.TemplateVersion(t, store).Seed(database.TemplateVersion{ OrganizationID: owner.OrganizationID, @@ -1083,33 +1113,41 @@ func TestTools(t *testing.T) { }, }).Do() - // nolint:gocritic // This is in a test package and does not end up in the build - ws1 := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + ws1Table := dbgen.Workspace(t, store, database.WorkspaceTable{ Name: "get-task-workspace-1", OrganizationID: owner.OrganizationID, OwnerID: member.ID, TemplateID: aiTV.Template.ID, - }).WithTask(nil).Do() + }) + task := dbgen.Task(t, store, database.TaskTable{ + OrganizationID: owner.OrganizationID, + OwnerID: member.ID, + Name: "get-task-1", + WorkspaceID: uuid.NullUUID{UUID: ws1Table.ID, Valid: true}, + TemplateVersionID: aiTV.TemplateVersion.ID, + Prompt: "get task", + }) + _ = dbfake.WorkspaceBuild(t, store, ws1Table).WithTask(nil).Do() tests := []struct { name string args toolsdk.GetTaskStatusArgs - expected codersdk.WorkspaceStatus + expected codersdk.TaskStatus error string }{ { name: "ByUUID", args: toolsdk.GetTaskStatusArgs{ - TaskID: ws1.Workspace.ID.String(), + TaskID: task.ID.String(), }, - expected: codersdk.WorkspaceStatusRunning, + expected: codersdk.TaskStatusInitializing, }, { - name: "ByWorkspaceIdentifier", + name: "ByIdentifier", args: toolsdk.GetTaskStatusArgs{ - TaskID: "get-task-workspace-1", + TaskID: task.Name, }, - expected: codersdk.WorkspaceStatusRunning, + expected: codersdk.TaskStatusInitializing, }, { name: "NoID", @@ -1301,8 +1339,6 @@ func TestTools(t *testing.T) { t.Run("SendTaskInput", func(t *testing.T) { t.Parallel() - t.Skip("TODO(mafredri): Remove, fixed down-stack!") - // Start a fake AgentAPI that accepts GET /status and POST /message. srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet && r.URL.Path == "/status" { @@ -1340,13 +1376,21 @@ func TestTools(t *testing.T) { }, }).Do() - // nolint:gocritic // This is in a test package and does not end up in the build - ws := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ - Name: "send-task-input", + wsTable := dbgen.Workspace(t, store, database.WorkspaceTable{ + Name: "send-task-input-ws", OrganizationID: owner.OrganizationID, OwnerID: member.ID, TemplateID: aiTV.Template.ID, - }).WithTask(&proto.App{Url: srv.URL}).Do() + }) + task := dbgen.Task(t, store, database.TaskTable{ + OrganizationID: owner.OrganizationID, + OwnerID: member.ID, + Name: "send-task-input", + WorkspaceID: uuid.NullUUID{UUID: wsTable.ID, Valid: true}, + TemplateVersionID: aiTV.TemplateVersion.ID, + Prompt: "send task input", + }) + ws := dbfake.WorkspaceBuild(t, store, wsTable).WithTask(&proto.App{Url: srv.URL}).Do() _ = agenttest.New(t, client.URL, ws.AgentToken) coderdtest.NewWorkspaceAgentWaiter(t, client, ws.Workspace.ID).Wait() @@ -1359,14 +1403,14 @@ func TestTools(t *testing.T) { { name: "ByUUID", args: toolsdk.SendTaskInputArgs{ - TaskID: ws.Workspace.ID.String(), + TaskID: task.ID.String(), Input: "frob the baz", }, }, { - name: "ByWorkspaceIdentifier", + name: "ByIdentifier", args: toolsdk.SendTaskInputArgs{ - TaskID: "send-task-input", + TaskID: task.Name, Input: "frob the baz", }, }, @@ -1404,7 +1448,7 @@ func TestTools(t *testing.T) { TaskID: r.Workspace.ID.String(), Input: "this is ignored", }, - error: "Task is not configured with a sidebar app", + error: "Resource not found", }, } @@ -1429,8 +1473,6 @@ func TestTools(t *testing.T) { t.Run("GetTaskLogs", func(t *testing.T) { t.Parallel() - t.Skip("TODO(mafredri): Remove, fixed down-stack!") - messages := []agentapi.Message{ { Id: 0, @@ -1471,13 +1513,21 @@ func TestTools(t *testing.T) { }, }).Do() - // nolint:gocritic // This is in a test package and does not end up in the build - ws := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ - Name: "get-task-logs", + wsTable := dbgen.Workspace(t, store, database.WorkspaceTable{ + Name: "get-task-logs-ws", OrganizationID: owner.OrganizationID, OwnerID: member.ID, TemplateID: aiTV.Template.ID, - }).WithTask(&proto.App{Url: srv.URL}).Do() + }) + task := dbgen.Task(t, store, database.TaskTable{ + OrganizationID: owner.OrganizationID, + OwnerID: member.ID, + Name: "get-task-logs", + WorkspaceID: uuid.NullUUID{UUID: wsTable.ID, Valid: true}, + TemplateVersionID: aiTV.TemplateVersion.ID, + Prompt: "get task logs", + }) + ws := dbfake.WorkspaceBuild(t, store, wsTable).WithTask(&proto.App{Url: srv.URL}).Do() _ = agenttest.New(t, client.URL, ws.AgentToken) coderdtest.NewWorkspaceAgentWaiter(t, client, ws.Workspace.ID).Wait() @@ -1491,14 +1541,14 @@ func TestTools(t *testing.T) { { name: "ByUUID", args: toolsdk.GetTaskLogsArgs{ - TaskID: ws.Workspace.ID.String(), + TaskID: task.ID.String(), }, expected: messages, }, { - name: "ByWorkspaceIdentifier", + name: "ByIdentifier", args: toolsdk.GetTaskLogsArgs{ - TaskID: "get-task-logs", + TaskID: task.Name, }, expected: messages, }, @@ -1526,7 +1576,7 @@ func TestTools(t *testing.T) { args: toolsdk.GetTaskLogsArgs{ TaskID: r.Workspace.ID.String(), }, - error: "Task is not configured with a sidebar app", + error: "Resource not found", }, } @@ -1777,23 +1827,6 @@ func TestMain(m *testing.M) { if runtime.GOOS == "windows" && tool.Name == "coder_workspace_bash" { continue } - ignored := false - for _, ignore := range []string{ - "coder_delete_task", - "coder_list_tasks", - "coder_get_task_status", - "coder_send_task_input", - "coder_get_task_logs", - "coder_get_task_logs", - } { - if ignore == tool.Name { - ignored = true - break - } - } - if ignored { - continue - } untested = append(untested, tool.Name) } } diff --git a/docs/reference/api/experimental.md b/docs/reference/api/experimental.md index e164e37aa5..3bb5fb03c7 100644 --- a/docs/reference/api/experimental.md +++ b/docs/reference/api/experimental.md @@ -15,12 +15,9 @@ curl -X GET http://coder-server:8080/api/v2/api/experimental/tasks \ ### Parameters -| Name | In | Type | Required | Description | -|------------|-------|---------|----------|-------------------------------------------| -| `q` | query | string | false | Search query for filtering tasks | -| `after_id` | query | string | false | Return tasks after this ID for pagination | -| `limit` | query | integer | false | Maximum number of tasks to return | -| `offset` | query | integer | false | Offset for pagination | +| Name | In | Type | Required | Description | +|------|-------|--------|----------|---------------------------------------------------------------------------------------------------------------------| +| `q` | query | string | false | Search query for filtering tasks. Supports: owner:, organization:, status: | ### Example responses diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 8e95dfd090..7427ef0066 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -4810,6 +4810,18 @@ export interface TasksFilter { * Owner can be a username, UUID, or "me". */ readonly owner?: string; + /** + * Organization can be an organization name or UUID. + */ + readonly organization?: string; + /** + * Status filters the tasks by their task status. + */ + readonly status?: TaskStatus; + /** + * FilterQuery allows specifying a raw filter query. + */ + readonly filter_query?: string; } // From codersdk/deployment.go