Skip to content

Commit 427b23f

Browse files
authored
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 } ```
1 parent fe36e9c commit 427b23f

File tree

5 files changed

+523
-1
lines changed

5 files changed

+523
-1
lines changed

coderd/aitasks.go

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
package coderd
22

33
import (
4+
"context"
45
"database/sql"
56
"errors"
67
"fmt"
78
"net/http"
89
"slices"
910
"strings"
1011

12+
"github.com/go-chi/chi/v5"
1113
"github.com/google/uuid"
14+
"golang.org/x/xerrors"
1215

1316
"cdr.dev/slog"
1417

@@ -17,6 +20,8 @@ import (
1720
"github.com/coder/coder/v2/coderd/httpapi"
1821
"github.com/coder/coder/v2/coderd/httpmw"
1922
"github.com/coder/coder/v2/coderd/rbac"
23+
"github.com/coder/coder/v2/coderd/rbac/policy"
24+
"github.com/coder/coder/v2/coderd/searchquery"
2025
"github.com/coder/coder/v2/coderd/taskname"
2126
"github.com/coder/coder/v2/codersdk"
2227
)
@@ -186,3 +191,252 @@ func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) {
186191
defer commitAudit()
187192
createWorkspace(ctx, aReq, apiKey.UserID, api, owner, createReq, rw, r)
188193
}
194+
195+
// tasksFromWorkspaces converts a slice of API workspaces into tasks, fetching
196+
// prompts and mapping status/state. This method enforces that only AI task
197+
// workspaces are given.
198+
func (api *API) tasksFromWorkspaces(ctx context.Context, apiWorkspaces []codersdk.Workspace) ([]codersdk.Task, error) {
199+
// Enforce that only AI task workspaces are given.
200+
for _, ws := range apiWorkspaces {
201+
if ws.LatestBuild.HasAITask == nil || !*ws.LatestBuild.HasAITask {
202+
return nil, xerrors.Errorf("workspace %s is not an AI task workspace", ws.ID)
203+
}
204+
}
205+
206+
// Fetch prompts for each workspace build and map by build ID.
207+
buildIDs := make([]uuid.UUID, 0, len(apiWorkspaces))
208+
for _, ws := range apiWorkspaces {
209+
buildIDs = append(buildIDs, ws.LatestBuild.ID)
210+
}
211+
parameters, err := api.Database.GetWorkspaceBuildParametersByBuildIDs(ctx, buildIDs)
212+
if err != nil {
213+
return nil, err
214+
}
215+
promptsByBuildID := make(map[uuid.UUID]string, len(parameters))
216+
for _, p := range parameters {
217+
if p.Name == codersdk.AITaskPromptParameterName {
218+
promptsByBuildID[p.WorkspaceBuildID] = p.Value
219+
}
220+
}
221+
222+
tasks := make([]codersdk.Task, 0, len(apiWorkspaces))
223+
for _, ws := range apiWorkspaces {
224+
var currentState *codersdk.TaskStateEntry
225+
if ws.LatestAppStatus != nil {
226+
currentState = &codersdk.TaskStateEntry{
227+
Timestamp: ws.LatestAppStatus.CreatedAt,
228+
State: codersdk.TaskState(ws.LatestAppStatus.State),
229+
Message: ws.LatestAppStatus.Message,
230+
URI: ws.LatestAppStatus.URI,
231+
}
232+
}
233+
tasks = append(tasks, codersdk.Task{
234+
ID: ws.ID,
235+
OrganizationID: ws.OrganizationID,
236+
OwnerID: ws.OwnerID,
237+
Name: ws.Name,
238+
TemplateID: ws.TemplateID,
239+
WorkspaceID: uuid.NullUUID{Valid: true, UUID: ws.ID},
240+
CreatedAt: ws.CreatedAt,
241+
UpdatedAt: ws.UpdatedAt,
242+
InitialPrompt: promptsByBuildID[ws.LatestBuild.ID],
243+
Status: ws.LatestBuild.Status,
244+
CurrentState: currentState,
245+
})
246+
}
247+
248+
return tasks, nil
249+
}
250+
251+
// tasksListResponse wraps a list of experimental tasks.
252+
//
253+
// Experimental: Response shape is experimental and may change.
254+
type tasksListResponse struct {
255+
Tasks []codersdk.Task `json:"tasks"`
256+
Count int `json:"count"`
257+
}
258+
259+
// tasksList is an experimental endpoint to list AI tasks by mapping
260+
// workspaces to a task-shaped response.
261+
func (api *API) tasksList(rw http.ResponseWriter, r *http.Request) {
262+
ctx := r.Context()
263+
apiKey := httpmw.APIKey(r)
264+
265+
// Support standard pagination/filters for workspaces.
266+
page, ok := ParsePagination(rw, r)
267+
if !ok {
268+
return
269+
}
270+
queryStr := r.URL.Query().Get("q")
271+
filter, errs := searchquery.Workspaces(ctx, api.Database, queryStr, page, api.AgentInactiveDisconnectTimeout)
272+
if len(errs) > 0 {
273+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
274+
Message: "Invalid workspace search query.",
275+
Validations: errs,
276+
})
277+
return
278+
}
279+
280+
// Ensure that we only include AI task workspaces in the results.
281+
filter.HasAITask = sql.NullBool{Valid: true, Bool: true}
282+
283+
if filter.OwnerUsername == "me" || filter.OwnerUsername == "" {
284+
filter.OwnerID = apiKey.UserID
285+
filter.OwnerUsername = ""
286+
}
287+
288+
prepared, err := api.HTTPAuth.AuthorizeSQLFilter(r, policy.ActionRead, rbac.ResourceWorkspace.Type)
289+
if err != nil {
290+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
291+
Message: "Internal error preparing sql filter.",
292+
Detail: err.Error(),
293+
})
294+
return
295+
}
296+
297+
// Order with requester's favorites first, include summary row.
298+
filter.RequesterID = apiKey.UserID
299+
filter.WithSummary = true
300+
301+
workspaceRows, err := api.Database.GetAuthorizedWorkspaces(ctx, filter, prepared)
302+
if err != nil {
303+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
304+
Message: "Internal error fetching workspaces.",
305+
Detail: err.Error(),
306+
})
307+
return
308+
}
309+
if len(workspaceRows) == 0 {
310+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
311+
Message: "Internal error fetching workspaces.",
312+
Detail: "Workspace summary row is missing.",
313+
})
314+
return
315+
}
316+
if len(workspaceRows) == 1 {
317+
httpapi.Write(ctx, rw, http.StatusOK, tasksListResponse{
318+
Tasks: []codersdk.Task{},
319+
Count: 0,
320+
})
321+
return
322+
}
323+
324+
// Skip summary row.
325+
workspaceRows = workspaceRows[:len(workspaceRows)-1]
326+
327+
workspaces := database.ConvertWorkspaceRows(workspaceRows)
328+
329+
// Gather associated data and convert to API workspaces.
330+
data, err := api.workspaceData(ctx, workspaces)
331+
if err != nil {
332+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
333+
Message: "Internal error fetching workspace resources.",
334+
Detail: err.Error(),
335+
})
336+
return
337+
}
338+
apiWorkspaces, err := convertWorkspaces(apiKey.UserID, workspaces, data)
339+
if err != nil {
340+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
341+
Message: "Internal error converting workspaces.",
342+
Detail: err.Error(),
343+
})
344+
return
345+
}
346+
347+
tasks, err := api.tasksFromWorkspaces(ctx, apiWorkspaces)
348+
if err != nil {
349+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
350+
Message: "Internal error fetching task prompts and states.",
351+
Detail: err.Error(),
352+
})
353+
return
354+
}
355+
356+
httpapi.Write(ctx, rw, http.StatusOK, tasksListResponse{
357+
Tasks: tasks,
358+
Count: len(tasks),
359+
})
360+
}
361+
362+
// taskGet is an experimental endpoint to fetch a single AI task by ID
363+
// (workspace ID). It returns a synthesized task response including
364+
// prompt and status.
365+
func (api *API) taskGet(rw http.ResponseWriter, r *http.Request) {
366+
ctx := r.Context()
367+
apiKey := httpmw.APIKey(r)
368+
369+
idStr := chi.URLParam(r, "id")
370+
taskID, err := uuid.Parse(idStr)
371+
if err != nil {
372+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
373+
Message: fmt.Sprintf("Invalid UUID %q for task ID.", idStr),
374+
})
375+
return
376+
}
377+
378+
// For now, taskID = workspaceID, once we have a task data model in
379+
// the DB, we can change this lookup.
380+
workspaceID := taskID
381+
workspace, err := api.Database.GetWorkspaceByID(ctx, workspaceID)
382+
if httpapi.Is404Error(err) {
383+
httpapi.ResourceNotFound(rw)
384+
return
385+
}
386+
if err != nil {
387+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
388+
Message: "Internal error fetching workspace.",
389+
Detail: err.Error(),
390+
})
391+
return
392+
}
393+
394+
data, err := api.workspaceData(ctx, []database.Workspace{workspace})
395+
if err != nil {
396+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
397+
Message: "Internal error fetching workspace resources.",
398+
Detail: err.Error(),
399+
})
400+
return
401+
}
402+
if len(data.builds) == 0 || len(data.templates) == 0 {
403+
httpapi.ResourceNotFound(rw)
404+
return
405+
}
406+
if data.builds[0].HasAITask == nil || !*data.builds[0].HasAITask {
407+
httpapi.ResourceNotFound(rw)
408+
return
409+
}
410+
411+
appStatus := codersdk.WorkspaceAppStatus{}
412+
if len(data.appStatuses) > 0 {
413+
appStatus = data.appStatuses[0]
414+
}
415+
416+
ws, err := convertWorkspace(
417+
apiKey.UserID,
418+
workspace,
419+
data.builds[0],
420+
data.templates[0],
421+
api.Options.AllowWorkspaceRenames,
422+
appStatus,
423+
)
424+
if err != nil {
425+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
426+
Message: "Internal error converting workspace.",
427+
Detail: err.Error(),
428+
})
429+
return
430+
}
431+
432+
tasks, err := api.tasksFromWorkspaces(ctx, []codersdk.Workspace{ws})
433+
if err != nil {
434+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
435+
Message: "Internal error fetching task prompt and state.",
436+
Detail: err.Error(),
437+
})
438+
return
439+
}
440+
441+
httpapi.Write(ctx, rw, http.StatusOK, tasks[0])
442+
}

0 commit comments

Comments
 (0)