Skip to content

Commit f8660ff

Browse files
committed
feat(coderd): add tasks list and get endpoints
Fixes coder/internal#899
1 parent 72f58c0 commit f8660ff

File tree

4 files changed

+552
-1
lines changed

4 files changed

+552
-1
lines changed

coderd/aitasks.go

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"slices"
99
"strings"
1010

11+
"github.com/go-chi/chi/v5"
1112
"github.com/google/uuid"
1213

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

0 commit comments

Comments
 (0)