feat(coderd): add tasks list and get endpoints (#19468)

Fixes coder/internal#899

Example API response:

```json
{
  "tasks": [
    {
      "id": "a7a27450-ca16-4553-a6c5-9d6f04808569",
      "organization_id": "241e869f-1a61-42c9-ae1e-9d46df874058",
      "owner_id": "9e9b9475-0fc0-47b2-9170-a5b7b9a075ee",
      "name": "task-hardcore-herschel-bd08",
      "template_id": "accab607-bbda-4794-89ac-da3926a8b71c",
      "workspace_id": "a7a27450-ca16-4553-a6c5-9d6f04808569",
      "initial_prompt": "What directory are you in?",
      "status": "running",
      "current_state": {
        "timestamp": "2025-08-22T10:03:27.837842Z",
        "state": "working",
        "message": "Listed root directory contents, working directory reset",
        "uri": ""
      },
      "created_at": "2025-08-22T09:21:39.697094Z",
      "updated_at": "2025-08-22T09:21:39.697094Z"
    },
    {
      "id": "50f92138-f463-4f2b-abad-1816264b065f",
      "organization_id": "241e869f-1a61-42c9-ae1e-9d46df874058",
      "owner_id": "9e9b9475-0fc0-47b2-9170-a5b7b9a075ee",
      "name": "task-musing-dewdney-f058",
      "template_id": "accab607-bbda-4794-89ac-da3926a8b71c",
      "workspace_id": "50f92138-f463-4f2b-abad-1816264b065f",
      "initial_prompt": "What is 1 + 1?",
      "status": "running",
      "current_state": {
        "timestamp": "2025-08-22T09:22:33.810707Z",
        "state": "idle",
        "message": "Completed arithmetic calculation",
        "uri": ""
      },
      "created_at": "2025-08-22T09:18:28.027378Z",
      "updated_at": "2025-08-22T09:18:28.027378Z"
    }
  ],
  "count": 2
}
```
This commit is contained in:
Mathias Fredriksson
2025-08-22 17:11:31 +03:00
committed by GitHub
parent fe36e9c120
commit 427b23f49a
5 changed files with 523 additions and 1 deletions
+254
View File
@@ -1,6 +1,7 @@
package coderd
import (
"context"
"database/sql"
"errors"
"fmt"
@@ -8,7 +9,9 @@ import (
"slices"
"strings"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"golang.org/x/xerrors"
"cdr.dev/slog"
@@ -17,6 +20,8 @@ import (
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/searchquery"
"github.com/coder/coder/v2/coderd/taskname"
"github.com/coder/coder/v2/codersdk"
)
@@ -186,3 +191,252 @@ func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) {
defer commitAudit()
createWorkspace(ctx, aReq, apiKey.UserID, api, owner, createReq, rw, r)
}
// 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) {
// Enforce that only AI task workspaces are given.
for _, ws := range apiWorkspaces {
if ws.LatestBuild.HasAITask == nil || !*ws.LatestBuild.HasAITask {
return nil, xerrors.Errorf("workspace %s is not an AI task workspace", ws.ID)
}
}
// 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 {
var currentState *codersdk.TaskStateEntry
if ws.LatestAppStatus != nil {
currentState = &codersdk.TaskStateEntry{
Timestamp: ws.LatestAppStatus.CreatedAt,
State: codersdk.TaskState(ws.LatestAppStatus.State),
Message: ws.LatestAppStatus.Message,
URI: ws.LatestAppStatus.URI,
}
}
tasks = append(tasks, codersdk.Task{
ID: ws.ID,
OrganizationID: ws.OrganizationID,
OwnerID: ws.OwnerID,
Name: ws.Name,
TemplateID: ws.TemplateID,
WorkspaceID: uuid.NullUUID{Valid: true, UUID: ws.ID},
CreatedAt: ws.CreatedAt,
UpdatedAt: ws.UpdatedAt,
InitialPrompt: promptsByBuildID[ws.LatestBuild.ID],
Status: ws.LatestBuild.Status,
CurrentState: currentState,
})
}
return tasks, nil
}
// tasksListResponse wraps a list of experimental tasks.
//
// Experimental: Response shape is experimental and may change.
type tasksListResponse struct {
Tasks []codersdk.Task `json:"tasks"`
Count int `json:"count"`
}
// tasksList is an experimental endpoint to list AI tasks by mapping
// workspaces to a task-shaped response.
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
}
queryStr := r.URL.Query().Get("q")
filter, errs := searchquery.Workspaces(ctx, api.Database, queryStr, page, api.AgentInactiveDisconnectTimeout)
if len(errs) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid workspace 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.OwnerUsername == "" {
filter.OwnerID = apiKey.UserID
filter.OwnerUsername = ""
}
prepared, err := api.HTTPAuth.AuthorizeSQLFilter(r, policy.ActionRead, rbac.ResourceWorkspace.Type)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error preparing sql filter.",
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)
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.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, tasksListResponse{
Tasks: tasks,
Count: len(tasks),
})
}
// taskGet is an experimental endpoint to fetch a single AI task by ID
// (workspace ID). It returns a synthesized task response including
// prompt and status.
func (api *API) taskGet(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
idStr := chi.URLParam(r, "id")
taskID, err := uuid.Parse(idStr)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("Invalid UUID %q for task ID.", idStr),
})
return
}
// For now, taskID = workspaceID, once we have a task data model in
// the DB, we can change this lookup.
workspaceID := taskID
workspace, err := api.Database.GetWorkspaceByID(ctx, workspaceID)
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace.",
Detail: err.Error(),
})
return
}
data, err := api.workspaceData(ctx, []database.Workspace{workspace})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace resources.",
Detail: err.Error(),
})
return
}
if len(data.builds) == 0 || len(data.templates) == 0 {
httpapi.ResourceNotFound(rw)
return
}
if data.builds[0].HasAITask == nil || !*data.builds[0].HasAITask {
httpapi.ResourceNotFound(rw)
return
}
appStatus := codersdk.WorkspaceAppStatus{}
if len(data.appStatuses) > 0 {
appStatus = data.appStatuses[0]
}
ws, err := convertWorkspace(
apiKey.UserID,
workspace,
data.builds[0],
data.templates[0],
api.Options.AllowWorkspaceRenames,
appStatus,
)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error converting workspace.",
Detail: err.Error(),
})
return
}
tasks, err := api.tasksFromWorkspaces(ctx, []codersdk.Workspace{ws})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching task prompt and state.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, tasks[0])
}
+126 -1
View File
@@ -10,6 +10,7 @@ import (
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/provisioner/echo"
"github.com/coder/coder/v2/provisionersdk/proto"
@@ -142,7 +143,131 @@ func TestAITasksPrompts(t *testing.T) {
})
}
func TestTaskCreate(t *testing.T) {
func TestTasks(t *testing.T) {
t.Parallel()
createAITemplate := func(t *testing.T, client *codersdk.Client, user codersdk.CreateFirstUserResponse) codersdk.Template {
t.Helper()
// Create a template version that supports AI tasks with the AI Prompt parameter.
taskAppID := uuid.New()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: []*proto.Response{
{
Type: &proto.Response_Plan{
Plan: &proto.PlanComplete{
Parameters: []*proto.RichParameter{{Name: codersdk.AITaskPromptParameterName, Type: "string"}},
HasAiTasks: true,
},
},
},
},
ProvisionApply: []*proto.Response{
{
Type: &proto.Response_Apply{
Apply: &proto.ApplyComplete{
Resources: []*proto.Resource{
{
Name: "example",
Type: "aws_instance",
Agents: []*proto.Agent{
{
Id: uuid.NewString(),
Name: "example",
Apps: []*proto.App{
{
Id: taskAppID.String(),
Slug: "task-sidebar",
DisplayName: "Task Sidebar",
},
},
},
},
},
},
AiTasks: []*proto.AITask{
{
SidebarApp: &proto.AITaskSidebarApp{
Id: taskAppID.String(),
},
},
},
},
},
},
},
})
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
return template
}
t.Run("List", func(t *testing.T) {
t.Parallel()
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.
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},
}
})
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 })
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.Status, "task status should not be empty")
})
t.Run("Get", func(t *testing.T) {
t.Parallel()
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.
wantPrompt := "review my code"
workspace := coderdtest.CreateWorkspace(t, client, template.ID, func(req *codersdk.CreateWorkspaceRequest) {
req.RichParameterValues = []codersdk.WorkspaceBuildParameter{
{Name: codersdk.AITaskPromptParameterName, Value: wantPrompt},
}
})
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
// Fetch the task by ID via experimental API and verify fields.
exp := codersdk.NewExperimentalClient(client)
task, err := exp.TaskByID(ctx, workspace.ID)
require.NoError(t, err)
assert.Equal(t, workspace.ID, task.ID, "task ID should match workspace ID")
assert.Equal(t, workspace.Name, task.Name, "task name should map from workspace name")
assert.Equal(t, wantPrompt, task.InitialPrompt, "task prompt should match the AI Prompt parameter")
assert.Equal(t, workspace.ID, task.WorkspaceID.UUID, "workspace id should match")
assert.NotEmpty(t, task.Status, "task status should not be empty")
})
}
func TestTasksCreate(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
+2
View File
@@ -1011,6 +1011,8 @@ func New(options *Options) *API {
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)
})
})