11package coderd
22
33import (
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) {
186191defer commitAudit ()
187192createWorkspace (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+ }