1
1
package coderd
2
2
3
3
import (
4
+ "context"
4
5
"database/sql"
5
6
"errors"
6
7
"fmt"
7
8
"net/http"
8
9
"slices"
9
10
"strings"
10
11
12
+ "github.com/go-chi/chi/v5"
11
13
"github.com/google/uuid"
14
+ "golang.org/x/xerrors"
12
15
13
16
"cdr.dev/slog"
14
17
@@ -17,6 +20,8 @@ import (
17
20
"github.com/coder/coder/v2/coderd/httpapi"
18
21
"github.com/coder/coder/v2/coderd/httpmw"
19
22
"github.com/coder/coder/v2/coderd/rbac"
23
+ "github.com/coder/coder/v2/coderd/rbac/policy"
24
+ "github.com/coder/coder/v2/coderd/searchquery"
20
25
"github.com/coder/coder/v2/coderd/taskname"
21
26
"github.com/coder/coder/v2/codersdk"
22
27
)
@@ -186,3 +191,252 @@ func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) {
186
191
defer commitAudit ()
187
192
createWorkspace (ctx , aReq , apiKey .UserID , api , owner , createReq , rw , r )
188
193
}
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