From bd14dddd00c964c31b009a9c2a4c8a7fe8eefe6e Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 21 Aug 2025 12:51:23 +0000 Subject: [PATCH 01/10] feat(coderd): add tasks list and get endpoints Fixes coder/internal#899 --- coderd/aitasks.go | 267 +++++++++++++++++++++++++++++++++++++++++ coderd/aitasks_test.go | 174 ++++++++++++++++++++++++++- coderd/coderd.go | 2 + codersdk/aitasks.go | 94 +++++++++++++++ 4 files changed, 536 insertions(+), 1 deletion(-) diff --git a/coderd/aitasks.go b/coderd/aitasks.go index 9ba201f11c0d6..993ef37eff85e 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -8,6 +8,7 @@ import ( "slices" "strings" + "github.com/go-chi/chi/v5" "github.com/google/uuid" "cdr.dev/slog" @@ -17,6 +18,8 @@ import ( "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/coderd/searchquery" "github.com/coder/coder/v2/coderd/taskname" "github.com/coder/coder/v2/codersdk" ) @@ -186,3 +189,267 @@ func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) { defer commitAudit() createWorkspace(ctx, aReq, apiKey.UserID, api, owner, createReq, rw, r) } + +// tasksListResponse wraps a list of experimental tasks. +// +// Experimental: Response shape is experimental and may change. +type tasksListResponse struct { + Tasks []codersdk.Task `json:"tasks"` + Count int `json:"count"` +} + +func mapTaskStatus(ws codersdk.Workspace) codersdk.TaskStatus { + if ws.LatestAppStatus != nil { + switch ws.LatestAppStatus.State { + case codersdk.WorkspaceAppStatusStateWorking: + return codersdk.TaskStatusWorking + case codersdk.WorkspaceAppStatusStateIdle: + return codersdk.TaskStatusIdle + case codersdk.WorkspaceAppStatusStateComplete: + return codersdk.TaskStatusCompleted + case codersdk.WorkspaceAppStatusStateFailure: + return codersdk.TaskStatusFailed + } + } + + switch ws.LatestBuild.Status { + case codersdk.WorkspaceStatusPending, codersdk.WorkspaceStatusStarting, codersdk.WorkspaceStatusRunning: + return codersdk.TaskStatusWorking + case codersdk.WorkspaceStatusStopping, codersdk.WorkspaceStatusStopped, codersdk.WorkspaceStatusDeleting, codersdk.WorkspaceStatusDeleted: + return codersdk.TaskStatusCompleted + case codersdk.WorkspaceStatusFailed, codersdk.WorkspaceStatusCanceling, codersdk.WorkspaceStatusCanceled: + return codersdk.TaskStatusFailed + default: + return codersdk.TaskStatusWorking + } +} + +// tasksList is an experimental endpoint to list AI tasks by mapping +// workspaces to a task-shaped response. +func (api *API) tasksList(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + apiKey := httpmw.APIKey(r) + + // Support standard pagination/filters for workspaces. + page, ok := ParsePagination(rw, r) + if !ok { + return + } + queryStr := r.URL.Query().Get("q") + filter, errs := searchquery.Workspaces(ctx, api.Database, queryStr, page, api.AgentInactiveDisconnectTimeout) + if len(errs) > 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid workspace search query.", + Validations: errs, + }) + return + } + + // Ensure that we only include AI task workspaces in the results. + filter.HasAITask = sql.NullBool{Valid: true, Bool: true} + + if filter.OwnerUsername == "me" || filter.OwnerUsername == "" { + filter.OwnerID = apiKey.UserID + filter.OwnerUsername = "" + } + + prepared, err := api.HTTPAuth.AuthorizeSQLFilter(r, policy.ActionRead, rbac.ResourceWorkspace.Type) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error preparing sql filter.", + Detail: err.Error(), + }) + return + } + + // Order with requester's favorites first, include summary row. + filter.RequesterID = apiKey.UserID + filter.WithSummary = true + + workspaceRows, err := api.Database.GetAuthorizedWorkspaces(ctx, filter, prepared) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching workspaces.", + Detail: err.Error(), + }) + return + } + if len(workspaceRows) == 0 { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching workspaces.", + Detail: "Workspace summary row is missing.", + }) + return + } + if len(workspaceRows) == 1 { + httpapi.Write(ctx, rw, http.StatusOK, tasksListResponse{ + Tasks: []codersdk.Task{}, + Count: 0, + }) + return + } + + // Skip summary row. + workspaceRows = workspaceRows[:len(workspaceRows)-1] + + workspaces := database.ConvertWorkspaceRows(workspaceRows) + + // Gather associated data and convert to API workspaces. + data, err := api.workspaceData(ctx, workspaces) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching workspace resources.", + Detail: err.Error(), + }) + return + } + apiWorkspaces, err := convertWorkspaces(apiKey.UserID, workspaces, data) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error converting workspaces.", + Detail: err.Error(), + }) + return + } + + // Fetch prompts for each workspace build and map by build ID. + buildIDs := make([]uuid.UUID, 0, len(apiWorkspaces)) + for _, ws := range apiWorkspaces { + buildIDs = append(buildIDs, ws.LatestBuild.ID) + } + parameters, err := api.Database.GetWorkspaceBuildParametersByBuildIDs(ctx, buildIDs) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching task prompts.", + Detail: err.Error(), + }) + return + } + promptsByBuildID := make(map[uuid.UUID]string, len(parameters)) + for _, p := range parameters { + if p.Name == codersdk.AITaskPromptParameterName { + promptsByBuildID[p.WorkspaceBuildID] = p.Value + } + } + + tasks := make([]codersdk.Task, 0, len(apiWorkspaces)) + for _, ws := range apiWorkspaces { + tasks = append(tasks, codersdk.Task{ + ID: ws.ID, + OrganizationID: ws.OrganizationID, + OwnerID: ws.OwnerID, + Name: ws.Name, + TemplateID: ws.TemplateID, + WorkspaceID: uuid.NullUUID{Valid: true, UUID: ws.ID}, + Prompt: promptsByBuildID[ws.LatestBuild.ID], + Status: mapTaskStatus(ws), + CreatedAt: ws.CreatedAt, + UpdatedAt: ws.UpdatedAt, + }) + } + + httpapi.Write(ctx, rw, http.StatusOK, tasksListResponse{ + Tasks: tasks, + Count: len(tasks), + }) +} + +// taskGet is an experimental endpoint to fetch a single AI task by ID +// (workspace ID). It returns a synthesized task response including +// prompt and status. +func (api *API) taskGet(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + apiKey := httpmw.APIKey(r) + + idStr := chi.URLParam(r, "id") + taskID, err := uuid.Parse(idStr) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("Invalid UUID %q for task ID.", idStr), + }) + return + } + + workspace, err := api.Database.GetWorkspaceByID(ctx, taskID) + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching workspace.", + Detail: err.Error(), + }) + return + } + + data, err := api.workspaceData(ctx, []database.Workspace{workspace}) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching workspace resources.", + Detail: err.Error(), + }) + return + } + if len(data.builds) == 0 || len(data.templates) == 0 { + httpapi.ResourceNotFound(rw) + return + } + if data.builds[0].HasAITask == nil || !*data.builds[0].HasAITask { + httpapi.ResourceNotFound(rw) + return + } + + appStatus := codersdk.WorkspaceAppStatus{} + if len(data.appStatuses) > 0 { + appStatus = data.appStatuses[0] + } + + ws, err := convertWorkspace( + apiKey.UserID, + workspace, + data.builds[0], + data.templates[0], + api.Options.AllowWorkspaceRenames, + appStatus, + ) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error converting workspace.", + Detail: err.Error(), + }) + return + } + + // Fetch the AI prompt from the build parameters. + params, err := api.Database.GetWorkspaceBuildParametersByBuildIDs(ctx, []uuid.UUID{ws.LatestBuild.ID}) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching task prompt.", + Detail: err.Error(), + }) + return + } + prompt := "" + for _, p := range params { + if p.Name == codersdk.AITaskPromptParameterName { + prompt = p.Value + break + } + } + + resp := codersdk.Task{ + ID: ws.ID, + OrganizationID: ws.OrganizationID, + OwnerID: ws.OwnerID, + Name: ws.Name, + TemplateID: ws.TemplateID, + WorkspaceID: uuid.NullUUID{Valid: true, UUID: ws.ID}, + Prompt: prompt, + Status: mapTaskStatus(ws), + CreatedAt: ws.CreatedAt, + UpdatedAt: ws.UpdatedAt, + } + + httpapi.Write(ctx, rw, http.StatusOK, resp) +} diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go index d4fecd2145f6d..300c1e7d8c362 100644 --- a/coderd/aitasks_test.go +++ b/coderd/aitasks_test.go @@ -142,7 +142,179 @@ func TestAITasksPrompts(t *testing.T) { }) } -func TestTaskCreate(t *testing.T) { +func TestTasks(t *testing.T) { + t.Parallel() + + t.Run("List", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + // Create a template version that supports AI tasks with the AI Prompt parameter. + taskAppID := uuid.New() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: []*proto.Response{ + { + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ + Parameters: []*proto.RichParameter{{Name: codersdk.AITaskPromptParameterName, Type: "string"}}, + HasAiTasks: true, + }, + }, + }, + }, + ProvisionApply: []*proto.Response{ + { + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ + Resources: []*proto.Resource{ + { + Name: "example", + Type: "aws_instance", + Agents: []*proto.Agent{ + { + Id: uuid.NewString(), + Name: "example", + Apps: []*proto.App{ + { + Id: taskAppID.String(), + Slug: "task-sidebar", + DisplayName: "Task Sidebar", + }, + }, + }, + }, + }, + }, + AiTasks: []*proto.AITask{ + { + SidebarApp: &proto.AITaskSidebarApp{ + Id: taskAppID.String(), + }, + }, + }, + }, + }, + }, + }, + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + // Create a workspace (task) with a specific prompt. + wantPrompt := "build me a web app" + workspace := coderdtest.CreateWorkspace(t, client, template.ID, func(req *codersdk.CreateWorkspaceRequest) { + req.RichParameterValues = []codersdk.WorkspaceBuildParameter{ + {Name: codersdk.AITaskPromptParameterName, Value: wantPrompt}, + } + }) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + // List tasks via experimental API and verify the prompt and status mapping. + exp := codersdk.NewExperimentalClient(client) + tasks, err := exp.Tasks(ctx, &codersdk.TasksFilter{Owner: codersdk.Me}) + require.NoError(t, err) + + var got codersdk.Task + for _, task := range tasks { + if task.ID == workspace.ID { + got = task + break + } + } + require.NotEqual(t, uuid.Nil, got.ID, "expected to find created task in list") + assert.Equal(t, wantPrompt, got.Prompt, "task prompt should match the AI Prompt parameter") + assert.Equal(t, workspace.Name, got.Name, "task name should map from workspace name") + assert.Equal(t, workspace.ID, got.WorkspaceID.UUID, "workspace id should match") + // Status should be populated via app status or workspace status mapping. + assert.NotEmpty(t, got.Status, "task status should not be empty") + }) + + t.Run("Get", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + // Create a template version that supports AI tasks with the AI Prompt parameter. + taskAppID := uuid.New() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: []*proto.Response{ + { + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ + Parameters: []*proto.RichParameter{{Name: codersdk.AITaskPromptParameterName, Type: "string"}}, + HasAiTasks: true, + }, + }, + }, + }, + ProvisionApply: []*proto.Response{ + { + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ + Resources: []*proto.Resource{ + { + Name: "example", + Type: "aws_instance", + Agents: []*proto.Agent{ + { + Id: uuid.NewString(), + Name: "example", + Apps: []*proto.App{ + { + Id: taskAppID.String(), + Slug: "task-sidebar", + DisplayName: "Task Sidebar", + }, + }, + }, + }, + }, + }, + AiTasks: []*proto.AITask{ + { + SidebarApp: &proto.AITaskSidebarApp{ + Id: taskAppID.String(), + }, + }, + }, + }, + }, + }, + }, + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + // Create a workspace (task) with a specific prompt. + wantPrompt := "review my code" + workspace := coderdtest.CreateWorkspace(t, client, template.ID, func(req *codersdk.CreateWorkspaceRequest) { + req.RichParameterValues = []codersdk.WorkspaceBuildParameter{ + {Name: codersdk.AITaskPromptParameterName, Value: wantPrompt}, + } + }) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + // Fetch the task by ID via experimental API and verify fields. + exp := codersdk.NewExperimentalClient(client) + task, err := exp.TaskByID(ctx, workspace.ID) + require.NoError(t, err) + + assert.Equal(t, workspace.ID, task.ID, "task ID should match workspace ID") + assert.Equal(t, workspace.Name, task.Name, "task name should map from workspace name") + assert.Equal(t, wantPrompt, task.Prompt, "task prompt should match the AI Prompt parameter") + assert.Equal(t, workspace.ID, task.WorkspaceID.UUID, "workspace id should match") + assert.NotEmpty(t, task.Status, "task status should not be empty") + }) +} + +func TestTasksCreate(t *testing.T) { t.Parallel() t.Run("OK", func(t *testing.T) { diff --git a/coderd/coderd.go b/coderd/coderd.go index 5debc13d21431..bb6f7b4fef4e5 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1011,6 +1011,8 @@ func New(options *Options) *API { r.Route("/{user}", func(r chi.Router) { r.Use(httpmw.ExtractOrganizationMembersParam(options.Database, api.HTTPAuth.Authorize)) + r.Get("/", api.tasksList) + r.Get("/{id}", api.taskGet) r.Post("/", api.tasksCreate) }) }) diff --git a/codersdk/aitasks.go b/codersdk/aitasks.go index 56b43d43a0d19..1520101472590 100644 --- a/codersdk/aitasks.go +++ b/codersdk/aitasks.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "strings" + "time" "github.com/google/uuid" @@ -70,3 +71,96 @@ func (c *ExperimentalClient) CreateTask(ctx context.Context, user string, reques return workspace, nil } + +// TaskStatus represents the high-level lifecycle of a task. +// +// Experimental: This type is experimental and may change in the future. +type TaskStatus string + +const ( + TaskStatusQueued TaskStatus = "queued" + TaskStatusWorking TaskStatus = "working" + TaskStatusIdle TaskStatus = "idle" + TaskStatusPaused TaskStatus = "paused" + TaskStatusCompleted TaskStatus = "completed" + TaskStatusFailed TaskStatus = "failed" +) + +// TasksFilter filters the list of tasks. +// +// Experimental: This type is experimental and may change in the future. +type TasksFilter struct { + // Owner can be a username, UUID, or "me" + Owner string `json:"owner,omitempty"` +} + +// Task represents a task. +// +// Experimental: This type is experimental and may change in the future. +type Task struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + OwnerID uuid.UUID `json:"owner_id"` + Name string `json:"name"` + TemplateID uuid.UUID `json:"template_id"` + WorkspaceID uuid.NullUUID `json:"workspace_id"` + Prompt string `json:"prompt"` + Status TaskStatus `json:"status"` + CreatedAt time.Time `json:"created_at" format:"date-time"` + UpdatedAt time.Time `json:"updated_at" format:"date-time"` +} + +// Tasks lists all tasks belonging to the user or specified owner. +// +// Experimental: This method is experimental and may change in the future. +func (c *ExperimentalClient) Tasks(ctx context.Context, filter *TasksFilter) ([]Task, error) { + if filter == nil { + filter = &TasksFilter{} + } + user := filter.Owner + if user == "" { + user = "me" + } + + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/tasks/%s", user), nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + + // Experimental response shape for tasks list (server returns []Task). + type tasksListResponse struct { + Tasks []Task `json:"tasks"` + Count int `json:"count"` + } + var tres tasksListResponse + if err := json.NewDecoder(res.Body).Decode(&tres); err != nil { + return nil, err + } + + return tres.Tasks, nil +} + +// TaskByID fetches a single experimental task by its ID. +// +// Experimental: This method is experimental and may change in the future. +func (c *ExperimentalClient) TaskByID(ctx context.Context, id uuid.UUID) (Task, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/tasks/%s/%s", "me", id.String()), nil) + if err != nil { + return Task{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return Task{}, ReadBodyAsError(res) + } + + var task Task + if err := json.NewDecoder(res.Body).Decode(&task); err != nil { + return Task{}, err + } + + return task, nil +} From 2fb2d4ba98aa2b3afe02dbdac40e854874f9325e Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 21 Aug 2025 13:09:08 +0000 Subject: [PATCH 02/10] make gen --- site/src/api/typesGenerated.ts | 37 ++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index db840040687fc..7bd29f05e867e 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2804,6 +2804,43 @@ export interface TailDERPRegion { readonly Nodes: readonly TailDERPNode[]; } +// From codersdk/aitasks.go +export interface Task { + readonly id: string; + readonly organization_id: string; + readonly owner_id: string; + readonly name: string; + readonly template_id: string; + readonly workspace_id: string | null; + readonly prompt: string; + readonly status: TaskStatus; + readonly created_at: string; + readonly updated_at: string; +} + +// From codersdk/aitasks.go +export type TaskStatus = + | "completed" + | "failed" + | "idle" + | "paused" + | "queued" + | "working"; + +export const TaskStatuses: TaskStatus[] = [ + "completed", + "failed", + "idle", + "paused", + "queued", + "working", +]; + +// From codersdk/aitasks.go +export interface TasksFilter { + readonly owner?: string; +} + // From codersdk/deployment.go export interface TelemetryConfig { readonly enable: boolean; From f301ba0d81206546912cd6c2429f22d7dc303eab Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 22 Aug 2025 08:22:39 +0000 Subject: [PATCH 03/10] swagger --- codersdk/aitasks.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/codersdk/aitasks.go b/codersdk/aitasks.go index 1520101472590..8dd4a2ef50a38 100644 --- a/codersdk/aitasks.go +++ b/codersdk/aitasks.go @@ -98,14 +98,14 @@ type TasksFilter struct { // // Experimental: This type is experimental and may change in the future. type Task struct { - ID uuid.UUID `json:"id"` - OrganizationID uuid.UUID `json:"organization_id"` - OwnerID uuid.UUID `json:"owner_id"` + ID uuid.UUID `json:"id" format:"uuid"` + OrganizationID uuid.UUID `json:"organization_id" format:"uuid"` + OwnerID uuid.UUID `json:"owner_id" format:"uuid"` Name string `json:"name"` - TemplateID uuid.UUID `json:"template_id"` - WorkspaceID uuid.NullUUID `json:"workspace_id"` + TemplateID uuid.UUID `json:"template_id" format:"uuid"` + WorkspaceID uuid.NullUUID `json:"workspace_id" format:"uuid"` Prompt string `json:"prompt"` - Status TaskStatus `json:"status"` + Status TaskStatus `json:"status" enum:"queued,working,idle,paused,completed,failed"` CreatedAt time.Time `json:"created_at" format:"date-time"` UpdatedAt time.Time `json:"updated_at" format:"date-time"` } From 59c9c7e9c96c845eaeccbb7b762e03e37881097b Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 22 Aug 2025 08:31:51 +0000 Subject: [PATCH 04/10] improve task status --- coderd/aitasks.go | 45 ++++++++++++++++++++++++++++----------------- codersdk/aitasks.go | 8 +++++--- 2 files changed, 33 insertions(+), 20 deletions(-) diff --git a/coderd/aitasks.go b/coderd/aitasks.go index 993ef37eff85e..fad561b5c7ce3 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -199,28 +199,39 @@ type tasksListResponse struct { } func mapTaskStatus(ws codersdk.Workspace) codersdk.TaskStatus { - if ws.LatestAppStatus != nil { - switch ws.LatestAppStatus.State { - case codersdk.WorkspaceAppStatusStateWorking: - return codersdk.TaskStatusWorking - case codersdk.WorkspaceAppStatusStateIdle: - return codersdk.TaskStatusIdle - case codersdk.WorkspaceAppStatusStateComplete: - return codersdk.TaskStatusCompleted - case codersdk.WorkspaceAppStatusStateFailure: - return codersdk.TaskStatusFailed + switch ws.LatestBuild.Status { + case codersdk.WorkspaceStatusPending: + return codersdk.TaskStatusPending + + case codersdk.WorkspaceStatusStarting: + return codersdk.TaskStatusStarting + + case codersdk.WorkspaceStatusRunning: + if ws.LatestAppStatus != nil { + switch ws.LatestAppStatus.State { + case codersdk.WorkspaceAppStatusStateWorking: + return codersdk.TaskStatusWorking + case codersdk.WorkspaceAppStatusStateIdle: + return codersdk.TaskStatusIdle + case codersdk.WorkspaceAppStatusStateComplete: + return codersdk.TaskStatusCompleted + case codersdk.WorkspaceAppStatusStateFailure: + return codersdk.TaskStatusFailed + } } - } + return codersdk.TaskStatusStarting + + case codersdk.WorkspaceStatusStopping, codersdk.WorkspaceStatusStopped: + return codersdk.TaskStatusStopping + + case codersdk.WorkspaceStatusDeleting, codersdk.WorkspaceStatusDeleted: + return codersdk.TaskStatusDeleting - switch ws.LatestBuild.Status { - case codersdk.WorkspaceStatusPending, codersdk.WorkspaceStatusStarting, codersdk.WorkspaceStatusRunning: - return codersdk.TaskStatusWorking - case codersdk.WorkspaceStatusStopping, codersdk.WorkspaceStatusStopped, codersdk.WorkspaceStatusDeleting, codersdk.WorkspaceStatusDeleted: - return codersdk.TaskStatusCompleted case codersdk.WorkspaceStatusFailed, codersdk.WorkspaceStatusCanceling, codersdk.WorkspaceStatusCanceled: return codersdk.TaskStatusFailed + default: - return codersdk.TaskStatusWorking + return codersdk.TaskStatusPending } } diff --git a/codersdk/aitasks.go b/codersdk/aitasks.go index 8dd4a2ef50a38..341b61db3d6f0 100644 --- a/codersdk/aitasks.go +++ b/codersdk/aitasks.go @@ -78,10 +78,12 @@ func (c *ExperimentalClient) CreateTask(ctx context.Context, user string, reques type TaskStatus string const ( - TaskStatusQueued TaskStatus = "queued" + TaskStatusPending TaskStatus = "pending" + TaskStatusStarting TaskStatus = "starting" + TaskStatusStopping TaskStatus = "stopping" + TaskStatusDeleting TaskStatus = "deleting" TaskStatusWorking TaskStatus = "working" TaskStatusIdle TaskStatus = "idle" - TaskStatusPaused TaskStatus = "paused" TaskStatusCompleted TaskStatus = "completed" TaskStatusFailed TaskStatus = "failed" ) @@ -105,7 +107,7 @@ type Task struct { TemplateID uuid.UUID `json:"template_id" format:"uuid"` WorkspaceID uuid.NullUUID `json:"workspace_id" format:"uuid"` Prompt string `json:"prompt"` - Status TaskStatus `json:"status" enum:"queued,working,idle,paused,completed,failed"` + Status TaskStatus `json:"status" enum:"pending,starting,stopping,deleting,working,idle,completed,failed"` CreatedAt time.Time `json:"created_at" format:"date-time"` UpdatedAt time.Time `json:"updated_at" format:"date-time"` } From 66fbc028c46600252646ec23aad33511dee25371 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 22 Aug 2025 09:48:32 +0000 Subject: [PATCH 05/10] refactor task status/state --- coderd/aitasks.go | 144 +++++++++++++-------------------- codersdk/aitasks.go | 59 ++++++++------ site/src/api/typesGenerated.ts | 12 ++- 3 files changed, 95 insertions(+), 120 deletions(-) diff --git a/coderd/aitasks.go b/coderd/aitasks.go index fad561b5c7ce3..376e24e1ea57b 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -1,6 +1,7 @@ package coderd import ( + "context" "database/sql" "errors" "fmt" @@ -190,49 +191,60 @@ func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) { createWorkspace(ctx, aReq, apiKey.UserID, api, owner, createReq, rw, r) } -// tasksListResponse wraps a list of experimental tasks. -// -// Experimental: Response shape is experimental and may change. -type tasksListResponse struct { - Tasks []codersdk.Task `json:"tasks"` - Count int `json:"count"` -} - -func mapTaskStatus(ws codersdk.Workspace) codersdk.TaskStatus { - switch ws.LatestBuild.Status { - case codersdk.WorkspaceStatusPending: - return codersdk.TaskStatusPending - - case codersdk.WorkspaceStatusStarting: - return codersdk.TaskStatusStarting +// tasksFromWorkspaces converts a slice of API workspaces into tasks, fetching +// prompts and mapping status/state. +func (api *API) tasksFromWorkspaces(ctx context.Context, apiWorkspaces []codersdk.Workspace) ([]codersdk.Task, error) { + // Fetch prompts for each workspace build and map by build ID. + buildIDs := make([]uuid.UUID, 0, len(apiWorkspaces)) + for _, ws := range apiWorkspaces { + buildIDs = append(buildIDs, ws.LatestBuild.ID) + } + parameters, err := api.Database.GetWorkspaceBuildParametersByBuildIDs(ctx, buildIDs) + if err != nil { + return nil, err + } + promptsByBuildID := make(map[uuid.UUID]string, len(parameters)) + for _, p := range parameters { + if p.Name == codersdk.AITaskPromptParameterName { + promptsByBuildID[p.WorkspaceBuildID] = p.Value + } + } - case codersdk.WorkspaceStatusRunning: + tasks := make([]codersdk.Task, 0, len(apiWorkspaces)) + for _, ws := range apiWorkspaces { + var currentState *codersdk.TaskStateEntry if ws.LatestAppStatus != nil { - switch ws.LatestAppStatus.State { - case codersdk.WorkspaceAppStatusStateWorking: - return codersdk.TaskStatusWorking - case codersdk.WorkspaceAppStatusStateIdle: - return codersdk.TaskStatusIdle - case codersdk.WorkspaceAppStatusStateComplete: - return codersdk.TaskStatusCompleted - case codersdk.WorkspaceAppStatusStateFailure: - return codersdk.TaskStatusFailed + currentState = &codersdk.TaskStateEntry{ + Timestamp: ws.LatestAppStatus.CreatedAt, + State: codersdk.TaskState(ws.LatestAppStatus.State), + Message: ws.LatestAppStatus.Message, + URI: ws.LatestAppStatus.URI, } } - return codersdk.TaskStatusStarting - - case codersdk.WorkspaceStatusStopping, codersdk.WorkspaceStatusStopped: - return codersdk.TaskStatusStopping - - case codersdk.WorkspaceStatusDeleting, codersdk.WorkspaceStatusDeleted: - return codersdk.TaskStatusDeleting + tasks = append(tasks, codersdk.Task{ + ID: ws.ID, + OrganizationID: ws.OrganizationID, + OwnerID: ws.OwnerID, + Name: ws.Name, + TemplateID: ws.TemplateID, + WorkspaceID: uuid.NullUUID{Valid: true, UUID: ws.ID}, + CreatedAt: ws.CreatedAt, + UpdatedAt: ws.UpdatedAt, + Prompt: promptsByBuildID[ws.LatestBuild.ID], + Status: ws.LatestBuild.Status, + CurrentState: currentState, + }) + } - case codersdk.WorkspaceStatusFailed, codersdk.WorkspaceStatusCanceling, codersdk.WorkspaceStatusCanceled: - return codersdk.TaskStatusFailed + return tasks, nil +} - default: - return codersdk.TaskStatusPending - } +// tasksListResponse wraps a list of experimental tasks. +// +// Experimental: Response shape is experimental and may change. +type tasksListResponse struct { + Tasks []codersdk.Task `json:"tasks"` + Count int `json:"count"` } // tasksList is an experimental endpoint to list AI tasks by mapping @@ -323,41 +335,14 @@ func (api *API) tasksList(rw http.ResponseWriter, r *http.Request) { return } - // Fetch prompts for each workspace build and map by build ID. - buildIDs := make([]uuid.UUID, 0, len(apiWorkspaces)) - for _, ws := range apiWorkspaces { - buildIDs = append(buildIDs, ws.LatestBuild.ID) - } - parameters, err := api.Database.GetWorkspaceBuildParametersByBuildIDs(ctx, buildIDs) + tasks, err := api.tasksFromWorkspaces(ctx, apiWorkspaces) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching task prompts.", + Message: "Internal error fetching task prompts and states.", Detail: err.Error(), }) return } - promptsByBuildID := make(map[uuid.UUID]string, len(parameters)) - for _, p := range parameters { - if p.Name == codersdk.AITaskPromptParameterName { - promptsByBuildID[p.WorkspaceBuildID] = p.Value - } - } - - tasks := make([]codersdk.Task, 0, len(apiWorkspaces)) - for _, ws := range apiWorkspaces { - tasks = append(tasks, codersdk.Task{ - ID: ws.ID, - OrganizationID: ws.OrganizationID, - OwnerID: ws.OwnerID, - Name: ws.Name, - TemplateID: ws.TemplateID, - WorkspaceID: uuid.NullUUID{Valid: true, UUID: ws.ID}, - Prompt: promptsByBuildID[ws.LatestBuild.ID], - Status: mapTaskStatus(ws), - CreatedAt: ws.CreatedAt, - UpdatedAt: ws.UpdatedAt, - }) - } httpapi.Write(ctx, rw, http.StatusOK, tasksListResponse{ Tasks: tasks, @@ -432,35 +417,14 @@ func (api *API) taskGet(rw http.ResponseWriter, r *http.Request) { return } - // Fetch the AI prompt from the build parameters. - params, err := api.Database.GetWorkspaceBuildParametersByBuildIDs(ctx, []uuid.UUID{ws.LatestBuild.ID}) + tasks, err := api.tasksFromWorkspaces(ctx, []codersdk.Workspace{ws}) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching task prompt.", + Message: "Internal error fetching task prompt and state.", Detail: err.Error(), }) return } - prompt := "" - for _, p := range params { - if p.Name == codersdk.AITaskPromptParameterName { - prompt = p.Value - break - } - } - - resp := codersdk.Task{ - ID: ws.ID, - OrganizationID: ws.OrganizationID, - OwnerID: ws.OwnerID, - Name: ws.Name, - TemplateID: ws.TemplateID, - WorkspaceID: uuid.NullUUID{Valid: true, UUID: ws.ID}, - Prompt: prompt, - Status: mapTaskStatus(ws), - CreatedAt: ws.CreatedAt, - UpdatedAt: ws.UpdatedAt, - } - httpapi.Write(ctx, rw, http.StatusOK, resp) + httpapi.Write(ctx, rw, http.StatusOK, tasks[0]) } diff --git a/codersdk/aitasks.go b/codersdk/aitasks.go index 341b61db3d6f0..a32e331043b89 100644 --- a/codersdk/aitasks.go +++ b/codersdk/aitasks.go @@ -72,44 +72,51 @@ func (c *ExperimentalClient) CreateTask(ctx context.Context, user string, reques return workspace, nil } -// TaskStatus represents the high-level lifecycle of a task. +// TaskState represents the high-level lifecycle of a task. // // Experimental: This type is experimental and may change in the future. -type TaskStatus string +type TaskState string const ( - TaskStatusPending TaskStatus = "pending" - TaskStatusStarting TaskStatus = "starting" - TaskStatusStopping TaskStatus = "stopping" - TaskStatusDeleting TaskStatus = "deleting" - TaskStatusWorking TaskStatus = "working" - TaskStatusIdle TaskStatus = "idle" - TaskStatusCompleted TaskStatus = "completed" - TaskStatusFailed TaskStatus = "failed" + TaskStateWorking TaskState = "working" + TaskStateIdle TaskState = "idle" + TaskStateCompleted TaskState = "completed" + TaskStateFailed TaskState = "failed" ) -// TasksFilter filters the list of tasks. +// Task represents a task. // // Experimental: This type is experimental and may change in the future. -type TasksFilter struct { - // Owner can be a username, UUID, or "me" - Owner string `json:"owner,omitempty"` +type Task struct { + ID uuid.UUID `json:"id" format:"uuid"` + OrganizationID uuid.UUID `json:"organization_id" format:"uuid"` + OwnerID uuid.UUID `json:"owner_id" format:"uuid"` + Name string `json:"name"` + TemplateID uuid.UUID `json:"template_id" format:"uuid"` + WorkspaceID uuid.NullUUID `json:"workspace_id" format:"uuid"` + Prompt string `json:"prompt"` + Status WorkspaceStatus `json:"status" enums:"pending,starting,running,stopping,stopped,failed,canceling,canceled,deleting,deleted"` + CurrentState *TaskStateEntry `json:"current_state"` + CreatedAt time.Time `json:"created_at" format:"date-time"` + UpdatedAt time.Time `json:"updated_at" format:"date-time"` } -// Task represents a task. +// TaskStateEntry represents a single entry in the task's state history. // // Experimental: This type is experimental and may change in the future. -type Task struct { - ID uuid.UUID `json:"id" format:"uuid"` - OrganizationID uuid.UUID `json:"organization_id" format:"uuid"` - OwnerID uuid.UUID `json:"owner_id" format:"uuid"` - Name string `json:"name"` - TemplateID uuid.UUID `json:"template_id" format:"uuid"` - WorkspaceID uuid.NullUUID `json:"workspace_id" format:"uuid"` - Prompt string `json:"prompt"` - Status TaskStatus `json:"status" enum:"pending,starting,stopping,deleting,working,idle,completed,failed"` - CreatedAt time.Time `json:"created_at" format:"date-time"` - UpdatedAt time.Time `json:"updated_at" format:"date-time"` +type TaskStateEntry struct { + Timestamp time.Time `json:"timestamp" format:"date-time"` + State TaskState `json:"state" enum:"working,idle,completed,failed"` + Message string `json:"message"` + URI string `json:"uri"` +} + +// TasksFilter filters the list of tasks. +// +// Experimental: This type is experimental and may change in the future. +type TasksFilter struct { + // Owner can be a username, UUID, or "me" + Owner string `json:"owner,omitempty"` } // Tasks lists all tasks belonging to the user or specified owner. diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 7bd29f05e867e..315221835c7be 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2821,18 +2821,22 @@ export interface Task { // From codersdk/aitasks.go export type TaskStatus = | "completed" + | "deleting" | "failed" | "idle" - | "paused" - | "queued" + | "pending" + | "starting" + | "stopping" | "working"; export const TaskStatuses: TaskStatus[] = [ "completed", + "deleting", "failed", "idle", - "paused", - "queued", + "pending", + "starting", + "stopping", "working", ]; From bb8ad89f7962d4648d8803db861b30094dd8a190 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 22 Aug 2025 10:11:04 +0000 Subject: [PATCH 06/10] initial prompt --- coderd/aitasks.go | 2 +- coderd/aitasks_test.go | 4 ++-- codersdk/aitasks.go | 2 +- site/src/api/typesGenerated.ts | 29 +++++++++++++---------------- 4 files changed, 17 insertions(+), 20 deletions(-) diff --git a/coderd/aitasks.go b/coderd/aitasks.go index 376e24e1ea57b..a83e6e3efaa58 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -230,7 +230,7 @@ func (api *API) tasksFromWorkspaces(ctx context.Context, apiWorkspaces []codersd WorkspaceID: uuid.NullUUID{Valid: true, UUID: ws.ID}, CreatedAt: ws.CreatedAt, UpdatedAt: ws.UpdatedAt, - Prompt: promptsByBuildID[ws.LatestBuild.ID], + InitialPrompt: promptsByBuildID[ws.LatestBuild.ID], Status: ws.LatestBuild.Status, CurrentState: currentState, }) diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go index 300c1e7d8c362..4daa7ad15e4ea 100644 --- a/coderd/aitasks_test.go +++ b/coderd/aitasks_test.go @@ -226,7 +226,7 @@ func TestTasks(t *testing.T) { } } require.NotEqual(t, uuid.Nil, got.ID, "expected to find created task in list") - assert.Equal(t, wantPrompt, got.Prompt, "task prompt should match the AI Prompt parameter") + assert.Equal(t, wantPrompt, got.InitialPrompt, "task prompt should match the AI Prompt parameter") assert.Equal(t, workspace.Name, got.Name, "task name should map from workspace name") assert.Equal(t, workspace.ID, got.WorkspaceID.UUID, "workspace id should match") // Status should be populated via app status or workspace status mapping. @@ -308,7 +308,7 @@ func TestTasks(t *testing.T) { assert.Equal(t, workspace.ID, task.ID, "task ID should match workspace ID") assert.Equal(t, workspace.Name, task.Name, "task name should map from workspace name") - assert.Equal(t, wantPrompt, task.Prompt, "task prompt should match the AI Prompt parameter") + assert.Equal(t, wantPrompt, task.InitialPrompt, "task prompt should match the AI Prompt parameter") assert.Equal(t, workspace.ID, task.WorkspaceID.UUID, "workspace id should match") assert.NotEmpty(t, task.Status, "task status should not be empty") }) diff --git a/codersdk/aitasks.go b/codersdk/aitasks.go index a32e331043b89..965b0fac1d493 100644 --- a/codersdk/aitasks.go +++ b/codersdk/aitasks.go @@ -94,7 +94,7 @@ type Task struct { Name string `json:"name"` TemplateID uuid.UUID `json:"template_id" format:"uuid"` WorkspaceID uuid.NullUUID `json:"workspace_id" format:"uuid"` - Prompt string `json:"prompt"` + InitialPrompt string `json:"initial_prompt"` Status WorkspaceStatus `json:"status" enums:"pending,starting,running,stopping,stopped,failed,canceling,canceled,deleting,deleted"` CurrentState *TaskStateEntry `json:"current_state"` CreatedAt time.Time `json:"created_at" format:"date-time"` diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 315221835c7be..0a69572e94e73 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2812,31 +2812,28 @@ export interface Task { readonly name: string; readonly template_id: string; readonly workspace_id: string | null; - readonly prompt: string; - readonly status: TaskStatus; + readonly initial_prompt: string; + readonly status: WorkspaceStatus; + readonly current_state: TaskStateEntry | null; readonly created_at: string; readonly updated_at: string; } // From codersdk/aitasks.go -export type TaskStatus = - | "completed" - | "deleting" - | "failed" - | "idle" - | "pending" - | "starting" - | "stopping" - | "working"; +export type TaskState = "completed" | "failed" | "idle" | "working"; -export const TaskStatuses: TaskStatus[] = [ +// From codersdk/aitasks.go +export interface TaskStateEntry { + readonly timestamp: string; + readonly state: TaskState; + readonly message: string; + readonly uri: string; +} + +export const TaskStates: TaskState[] = [ "completed", - "deleting", "failed", "idle", - "pending", - "starting", - "stopping", "working", ]; From c14753a6824732c279450475679e1bca07fdf325 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 22 Aug 2025 10:48:23 +0000 Subject: [PATCH 07/10] pr feedback on test --- coderd/aitasks_test.go | 83 +++++++++--------------------------------- 1 file changed, 18 insertions(+), 65 deletions(-) diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go index 4daa7ad15e4ea..3a479a7657642 100644 --- a/coderd/aitasks_test.go +++ b/coderd/aitasks_test.go @@ -10,6 +10,7 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisioner/echo" "github.com/coder/coder/v2/provisionersdk/proto" @@ -145,12 +146,8 @@ func TestAITasksPrompts(t *testing.T) { func TestTasks(t *testing.T) { t.Parallel() - t.Run("List", func(t *testing.T) { - t.Parallel() - - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - ctx := testutil.Context(t, testutil.WaitLong) + createAITemplate := func(t *testing.T, client *coderdtest.Client, user coderdtest.User) codersdk.Template { + t.Helper() // Create a template version that supports AI tasks with the AI Prompt parameter. taskAppID := uuid.New() @@ -204,6 +201,18 @@ func TestTasks(t *testing.T) { coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + return template + } + + t.Run("List", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + template := createAITemplate(t, client, user) + // Create a workspace (task) with a specific prompt. wantPrompt := "build me a web app" workspace := coderdtest.CreateWorkspace(t, client, template.ID, func(req *codersdk.CreateWorkspaceRequest) { @@ -218,14 +227,8 @@ func TestTasks(t *testing.T) { tasks, err := exp.Tasks(ctx, &codersdk.TasksFilter{Owner: codersdk.Me}) require.NoError(t, err) - var got codersdk.Task - for _, task := range tasks { - if task.ID == workspace.ID { - got = task - break - } - } - require.NotEqual(t, uuid.Nil, got.ID, "expected to find created task in list") + got, ok := slice.Find(tasks, func(task codersdk.Task) bool { return task.ID == workspace.ID }) + require.True(t, ok, "task should be found in the list") assert.Equal(t, wantPrompt, got.InitialPrompt, "task prompt should match the AI Prompt parameter") assert.Equal(t, workspace.Name, got.Name, "task name should map from workspace name") assert.Equal(t, workspace.ID, got.WorkspaceID.UUID, "workspace id should match") @@ -240,57 +243,7 @@ func TestTasks(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) - // Create a template version that supports AI tasks with the AI Prompt parameter. - taskAppID := uuid.New() - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionPlan: []*proto.Response{ - { - Type: &proto.Response_Plan{ - Plan: &proto.PlanComplete{ - Parameters: []*proto.RichParameter{{Name: codersdk.AITaskPromptParameterName, Type: "string"}}, - HasAiTasks: true, - }, - }, - }, - }, - ProvisionApply: []*proto.Response{ - { - Type: &proto.Response_Apply{ - Apply: &proto.ApplyComplete{ - Resources: []*proto.Resource{ - { - Name: "example", - Type: "aws_instance", - Agents: []*proto.Agent{ - { - Id: uuid.NewString(), - Name: "example", - Apps: []*proto.App{ - { - Id: taskAppID.String(), - Slug: "task-sidebar", - DisplayName: "Task Sidebar", - }, - }, - }, - }, - }, - }, - AiTasks: []*proto.AITask{ - { - SidebarApp: &proto.AITaskSidebarApp{ - Id: taskAppID.String(), - }, - }, - }, - }, - }, - }, - }, - }) - coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + template := createAITemplate(t, client, user) // Create a workspace (task) with a specific prompt. wantPrompt := "review my code" From e7c22ddd90a0d30eaaf6feb130490ae4ba4b9857 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 22 Aug 2025 13:23:49 +0000 Subject: [PATCH 08/10] enforce ai task workspaces --- coderd/aitasks.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/coderd/aitasks.go b/coderd/aitasks.go index a83e6e3efaa58..d67bafc84d74c 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -192,8 +192,16 @@ func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) { } // tasksFromWorkspaces converts a slice of API workspaces into tasks, fetching -// prompts and mapping status/state. +// prompts and mapping status/state. This method enforces that only AI task +// workspaces are given. func (api *API) tasksFromWorkspaces(ctx context.Context, apiWorkspaces []codersdk.Workspace) ([]codersdk.Task, error) { + // Enforce that only AI task workspaces are given. + for _, ws := range apiWorkspaces { + if ws.LatestBuild.HasAITask == nil || !*ws.LatestBuild.HasAITask { + return nil, fmt.Errorf("workspace %s is not an AI task workspace", ws.ID) + } + } + // Fetch prompts for each workspace build and map by build ID. buildIDs := make([]uuid.UUID, 0, len(apiWorkspaces)) for _, ws := range apiWorkspaces { From 0c439d83607338ce68a1be2dd3a9fb8b05681644 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 22 Aug 2025 13:27:17 +0000 Subject: [PATCH 09/10] little docs --- coderd/aitasks.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/coderd/aitasks.go b/coderd/aitasks.go index d67bafc84d74c..6d61e2b21e0e2 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -374,7 +374,10 @@ func (api *API) taskGet(rw http.ResponseWriter, r *http.Request) { return } - workspace, err := api.Database.GetWorkspaceByID(ctx, taskID) + // For now, taskID = workspaceID, once we have a task data model in + // the DB, we can change this lookup. + workspaceID := taskID + workspace, err := api.Database.GetWorkspaceByID(ctx, workspaceID) if httpapi.Is404Error(err) { httpapi.ResourceNotFound(rw) return From fdad499012357503b7e168e0547169b05672dff6 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 22 Aug 2025 13:33:52 +0000 Subject: [PATCH 10/10] fix --- coderd/aitasks.go | 3 ++- coderd/aitasks_test.go | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/coderd/aitasks.go b/coderd/aitasks.go index 6d61e2b21e0e2..de607e7619f77 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -11,6 +11,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/google/uuid" + "golang.org/x/xerrors" "cdr.dev/slog" @@ -198,7 +199,7 @@ func (api *API) tasksFromWorkspaces(ctx context.Context, apiWorkspaces []codersd // Enforce that only AI task workspaces are given. for _, ws := range apiWorkspaces { if ws.LatestBuild.HasAITask == nil || !*ws.LatestBuild.HasAITask { - return nil, fmt.Errorf("workspace %s is not an AI task workspace", ws.ID) + return nil, xerrors.Errorf("workspace %s is not an AI task workspace", ws.ID) } } diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go index 3a479a7657642..131238de8a5bd 100644 --- a/coderd/aitasks_test.go +++ b/coderd/aitasks_test.go @@ -146,7 +146,7 @@ func TestAITasksPrompts(t *testing.T) { func TestTasks(t *testing.T) { t.Parallel() - createAITemplate := func(t *testing.T, client *coderdtest.Client, user coderdtest.User) codersdk.Template { + createAITemplate := func(t *testing.T, client *codersdk.Client, user codersdk.CreateFirstUserResponse) codersdk.Template { t.Helper() // Create a template version that supports AI tasks with the AI Prompt parameter. 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