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) {
186189defer commitAudit ()
187190createWorkspace (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+ }