diff --git a/coderd/aitasks.go b/coderd/aitasks.go index 9ba201f11c..de607e7619 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -1,6 +1,7 @@ package coderd import ( + "context" "database/sql" "errors" "fmt" @@ -8,7 +9,9 @@ import ( "slices" "strings" + "github.com/go-chi/chi/v5" "github.com/google/uuid" + "golang.org/x/xerrors" "cdr.dev/slog" @@ -17,6 +20,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 +191,252 @@ func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) { defer commitAudit() createWorkspace(ctx, aReq, apiKey.UserID, api, owner, createReq, rw, r) } + +// tasksFromWorkspaces converts a slice of API workspaces into tasks, fetching +// 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, xerrors.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 { + 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 + } + } + + tasks := make([]codersdk.Task, 0, len(apiWorkspaces)) + for _, ws := range apiWorkspaces { + var currentState *codersdk.TaskStateEntry + if ws.LatestAppStatus != nil { + currentState = &codersdk.TaskStateEntry{ + Timestamp: ws.LatestAppStatus.CreatedAt, + State: codersdk.TaskState(ws.LatestAppStatus.State), + Message: ws.LatestAppStatus.Message, + URI: ws.LatestAppStatus.URI, + } + } + 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, + InitialPrompt: promptsByBuildID[ws.LatestBuild.ID], + Status: ws.LatestBuild.Status, + CurrentState: currentState, + }) + } + + return tasks, nil +} + +// 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 +// 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 + } + + tasks, err := api.tasksFromWorkspaces(ctx, apiWorkspaces) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching task prompts and states.", + Detail: err.Error(), + }) + return + } + + 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 + } + + // 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 + } + 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 + } + + 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 and state.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, tasks[0]) +} diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go index d4fecd2145..131238de8a 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" @@ -142,7 +143,131 @@ func TestAITasksPrompts(t *testing.T) { }) } -func TestTaskCreate(t *testing.T) { +func TestTasks(t *testing.T) { + t.Parallel() + + 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. + 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) + + 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) { + 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) + + 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") + // 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) + + template := createAITemplate(t, client, user) + + // 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.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") + }) +} + +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 5debc13d21..bb6f7b4fef 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 56b43d43a0..965b0fac1d 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,105 @@ func (c *ExperimentalClient) CreateTask(ctx context.Context, user string, reques return workspace, nil } + +// TaskState represents the high-level lifecycle of a task. +// +// Experimental: This type is experimental and may change in the future. +type TaskState string + +const ( + TaskStateWorking TaskState = "working" + TaskStateIdle TaskState = "idle" + TaskStateCompleted TaskState = "completed" + TaskStateFailed TaskState = "failed" +) + +// Task represents a task. +// +// 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"` + 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"` + UpdatedAt time.Time `json:"updated_at" format:"date-time"` +} + +// TaskStateEntry represents a single entry in the task's state history. +// +// Experimental: This type is experimental and may change in the future. +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. +// +// 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 +} diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index a6610e3327..58167d7d27 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2807,6 +2807,44 @@ 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 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 TaskState = "completed" | "failed" | "idle" | "working"; + +// From codersdk/aitasks.go +export interface TaskStateEntry { + readonly timestamp: string; + readonly state: TaskState; + readonly message: string; + readonly uri: string; +} + +export const TaskStates: TaskState[] = [ + "completed", + "failed", + "idle", + "working", +]; + +// From codersdk/aitasks.go +export interface TasksFilter { + readonly owner?: string; +} + // From codersdk/deployment.go export interface TelemetryConfig { readonly enable: boolean;