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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy