Skip to content

Commit bd14ddd

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

File tree

4 files changed

+536
-1
lines changed

4 files changed

+536
-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)
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