mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
eb74732839
Renames the `prompt` field to `input` for task creation. This matches the naming used in the CLI and elsewhere.
972 lines
30 KiB
Go
972 lines
30 KiB
Go
package coderd
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"path"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
|
|
"cdr.dev/slog"
|
|
"github.com/coder/coder/v2/coderd/audit"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/httpapi"
|
|
"github.com/coder/coder/v2/coderd/httpapi/httperror"
|
|
"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/coderd/util/slice"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
// This endpoint is experimental and not guaranteed to be stable, so we're not
|
|
// generating public-facing documentation for it.
|
|
func (api *API) aiTasksPrompts(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
buildIDsParam := r.URL.Query().Get("build_ids")
|
|
if buildIDsParam == "" {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "build_ids query parameter is required",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Parse build IDs
|
|
buildIDStrings := strings.Split(buildIDsParam, ",")
|
|
buildIDs := make([]uuid.UUID, 0, len(buildIDStrings))
|
|
for _, idStr := range buildIDStrings {
|
|
id, err := uuid.Parse(strings.TrimSpace(idStr))
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: fmt.Sprintf("Invalid build ID format: %s", idStr),
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
buildIDs = append(buildIDs, id)
|
|
}
|
|
|
|
parameters, err := api.Database.GetWorkspaceBuildParametersByBuildIDs(ctx, buildIDs)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching workspace build parameters.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
promptsByBuildID := make(map[string]string, len(parameters))
|
|
for _, param := range parameters {
|
|
if param.Name != codersdk.AITaskPromptParameterName {
|
|
continue
|
|
}
|
|
buildID := param.WorkspaceBuildID.String()
|
|
promptsByBuildID[buildID] = param.Value
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, codersdk.AITasksPromptsResponse{
|
|
Prompts: promptsByBuildID,
|
|
})
|
|
}
|
|
|
|
// This endpoint is experimental and not guaranteed to be stable, so we're not
|
|
// generating public-facing documentation for it.
|
|
func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) {
|
|
var (
|
|
ctx = r.Context()
|
|
apiKey = httpmw.APIKey(r)
|
|
auditor = api.Auditor.Load()
|
|
mems = httpmw.OrganizationMembersParam(r)
|
|
)
|
|
|
|
var req codersdk.CreateTaskRequest
|
|
if !httpapi.Read(ctx, rw, r, &req) {
|
|
return
|
|
}
|
|
|
|
hasAITask, err := api.Database.GetTemplateVersionHasAITask(ctx, req.TemplateVersionID)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) || rbac.IsUnauthorizedError(err) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching whether the template version has an AI task.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
if !hasAITask {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: fmt.Sprintf(`Template does not have required parameter %q`, codersdk.AITaskPromptParameterName),
|
|
})
|
|
return
|
|
}
|
|
|
|
taskName := req.Name
|
|
if taskName != "" {
|
|
if err := codersdk.NameValid(taskName); err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Unable to create a Task with the provided name.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
if taskName == "" {
|
|
taskName = taskname.GenerateFallback()
|
|
|
|
if anthropicAPIKey := taskname.GetAnthropicAPIKeyFromEnv(); anthropicAPIKey != "" {
|
|
anthropicModel := taskname.GetAnthropicModelFromEnv()
|
|
|
|
generatedName, err := taskname.Generate(ctx, req.Input, taskname.WithAPIKey(anthropicAPIKey), taskname.WithModel(anthropicModel))
|
|
if err != nil {
|
|
api.Logger.Error(ctx, "unable to generate task name", slog.Error(err))
|
|
} else {
|
|
taskName = generatedName
|
|
}
|
|
}
|
|
}
|
|
|
|
createReq := codersdk.CreateWorkspaceRequest{
|
|
Name: taskName,
|
|
TemplateVersionID: req.TemplateVersionID,
|
|
TemplateVersionPresetID: req.TemplateVersionPresetID,
|
|
RichParameterValues: []codersdk.WorkspaceBuildParameter{
|
|
{Name: codersdk.AITaskPromptParameterName, Value: req.Input},
|
|
},
|
|
}
|
|
|
|
var owner workspaceOwner
|
|
if mems.User != nil {
|
|
// This user fetch is an optimization path for the most common case of creating a
|
|
// task for 'Me'.
|
|
//
|
|
// This is also required to allow `owners` to create workspaces for users
|
|
// that are not in an organization.
|
|
owner = workspaceOwner{
|
|
ID: mems.User.ID,
|
|
Username: mems.User.Username,
|
|
AvatarURL: mems.User.AvatarURL,
|
|
}
|
|
} else {
|
|
// A task can still be created if the caller can read the organization
|
|
// member. The organization is required, which can be sourced from the
|
|
// template.
|
|
//
|
|
// TODO: This code gets called twice for each workspace build request.
|
|
// This is inefficient and costs at most 2 extra RTTs to the DB.
|
|
// This can be optimized. It exists as it is now for code simplicity.
|
|
// The most common case is to create a workspace for 'Me'. Which does
|
|
// not enter this code branch.
|
|
template, err := requestTemplate(ctx, createReq, api.Database)
|
|
if err != nil {
|
|
httperror.WriteResponseError(ctx, rw, err)
|
|
return
|
|
}
|
|
|
|
// If the caller can find the organization membership in the same org
|
|
// as the template, then they can continue.
|
|
orgIndex := slices.IndexFunc(mems.Memberships, func(mem httpmw.OrganizationMember) bool {
|
|
return mem.OrganizationID == template.OrganizationID
|
|
})
|
|
if orgIndex == -1 {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
|
|
member := mems.Memberships[orgIndex]
|
|
owner = workspaceOwner{
|
|
ID: member.UserID,
|
|
Username: member.Username,
|
|
AvatarURL: member.AvatarURL,
|
|
}
|
|
}
|
|
|
|
aReq, commitAudit := audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{
|
|
Audit: *auditor,
|
|
Log: api.Logger,
|
|
Request: r,
|
|
Action: database.AuditActionCreate,
|
|
AdditionalFields: audit.AdditionalFields{
|
|
WorkspaceOwner: owner.Username,
|
|
},
|
|
})
|
|
defer commitAudit()
|
|
w, err := createWorkspace(ctx, aReq, apiKey.UserID, api, owner, createReq, r)
|
|
if err != nil {
|
|
httperror.WriteResponseError(ctx, rw, err)
|
|
return
|
|
}
|
|
|
|
task := taskFromWorkspace(w, req.Input)
|
|
httpapi.Write(ctx, rw, http.StatusCreated, task)
|
|
}
|
|
|
|
func taskFromWorkspace(ws codersdk.Workspace, initialPrompt string) codersdk.Task {
|
|
// TODO(DanielleMaywood):
|
|
// This just picks up the first agent it discovers.
|
|
// This approach _might_ break when a task has multiple agents,
|
|
// depending on which agent was found first.
|
|
//
|
|
// We explicitly do not have support for running tasks
|
|
// inside of a sub agent at the moment, so we can be sure
|
|
// that any sub agents are not the agent we're looking for.
|
|
var taskAgentID uuid.NullUUID
|
|
var taskAgentLifecycle *codersdk.WorkspaceAgentLifecycle
|
|
var taskAgentHealth *codersdk.WorkspaceAgentHealth
|
|
for _, resource := range ws.LatestBuild.Resources {
|
|
for _, agent := range resource.Agents {
|
|
if agent.ParentID.Valid {
|
|
continue
|
|
}
|
|
|
|
taskAgentID = uuid.NullUUID{Valid: true, UUID: agent.ID}
|
|
taskAgentLifecycle = &agent.LifecycleState
|
|
taskAgentHealth = &agent.Health
|
|
break
|
|
}
|
|
}
|
|
|
|
// Ignore 'latest app status' if it is older than the latest build and the latest build is a 'start' transition.
|
|
// This ensures that you don't show a stale app status from a previous build.
|
|
// For stop transitions, there is still value in showing the latest app status.
|
|
var currentState *codersdk.TaskStateEntry
|
|
if ws.LatestAppStatus != nil {
|
|
if ws.LatestBuild.Transition != codersdk.WorkspaceTransitionStart || ws.LatestAppStatus.CreatedAt.After(ws.LatestBuild.CreatedAt) {
|
|
currentState = &codersdk.TaskStateEntry{
|
|
Timestamp: ws.LatestAppStatus.CreatedAt,
|
|
State: codersdk.TaskState(ws.LatestAppStatus.State),
|
|
Message: ws.LatestAppStatus.Message,
|
|
URI: ws.LatestAppStatus.URI,
|
|
}
|
|
}
|
|
}
|
|
|
|
return codersdk.Task{
|
|
ID: ws.ID,
|
|
OrganizationID: ws.OrganizationID,
|
|
OwnerID: ws.OwnerID,
|
|
OwnerName: ws.OwnerName,
|
|
Name: ws.Name,
|
|
TemplateID: ws.TemplateID,
|
|
TemplateName: ws.TemplateName,
|
|
TemplateDisplayName: ws.TemplateDisplayName,
|
|
TemplateIcon: ws.TemplateIcon,
|
|
WorkspaceID: uuid.NullUUID{Valid: true, UUID: ws.ID},
|
|
WorkspaceAgentID: taskAgentID,
|
|
WorkspaceAgentLifecycle: taskAgentLifecycle,
|
|
WorkspaceAgentHealth: taskAgentHealth,
|
|
CreatedAt: ws.CreatedAt,
|
|
UpdatedAt: ws.UpdatedAt,
|
|
InitialPrompt: initialPrompt,
|
|
Status: ws.LatestBuild.Status,
|
|
CurrentState: currentState,
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
// 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 {
|
|
tasks = append(tasks, taskFromWorkspace(ws, promptsByBuildID[ws.LatestBuild.ID]))
|
|
}
|
|
|
|
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.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 {
|
|
// TODO(DanielleMaywood):
|
|
// This is a temporary workaround. When a task has just been created, but
|
|
// not yet provisioned, the workspace build will not have `HasAITask` set.
|
|
//
|
|
// When we reach this code flow, it is _either_ because the workspace is
|
|
// not a task, or it is a task that has not yet been provisioned. This
|
|
// endpoint should rarely be called with a non-task workspace so we
|
|
// should be fine with this extra database call to check if it has the
|
|
// special "AI Task" parameter.
|
|
parameters, err := api.Database.GetWorkspaceBuildParameters(ctx, data.builds[0].ID)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching workspace build parameters.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
_, hasAITask := slice.Find(parameters, func(t database.WorkspaceBuildParameter) bool {
|
|
return t.Name == codersdk.AITaskPromptParameterName
|
|
})
|
|
|
|
if !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])
|
|
}
|
|
|
|
// taskDelete is an experimental endpoint to delete a task by ID (workspace ID).
|
|
// It creates a delete workspace build and returns 202 Accepted if the build was
|
|
// created.
|
|
func (api *API) taskDelete(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
|
|
}
|
|
|
|
// Construct a request to the workspace build creation handler to
|
|
// initiate deletion.
|
|
buildReq := codersdk.CreateWorkspaceBuildRequest{
|
|
Transition: codersdk.WorkspaceTransitionDelete,
|
|
Reason: "Deleted via tasks API",
|
|
}
|
|
|
|
_, err = api.postWorkspaceBuildsInternal(
|
|
ctx,
|
|
apiKey,
|
|
workspace,
|
|
buildReq,
|
|
func(action policy.Action, object rbac.Objecter) bool {
|
|
return api.Authorize(r, action, object)
|
|
},
|
|
audit.WorkspaceBuildBaggageFromRequest(r),
|
|
)
|
|
if err != nil {
|
|
httperror.WriteWorkspaceBuildError(ctx, rw, err)
|
|
return
|
|
}
|
|
|
|
// Delete build created successfully.
|
|
rw.WriteHeader(http.StatusAccepted)
|
|
}
|
|
|
|
// taskSend submits task input to the tasks sidebar app by dialing the agent
|
|
// directly over the tailnet. We enforce ApplicationConnect RBAC on the
|
|
// workspace and validate the sidebar app health.
|
|
func (api *API) taskSend(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
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
|
|
}
|
|
|
|
var req codersdk.TaskSendRequest
|
|
if !httpapi.Read(ctx, rw, r, &req) {
|
|
return
|
|
}
|
|
if req.Input == "" {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Task input is required.",
|
|
})
|
|
return
|
|
}
|
|
|
|
if err = api.authAndDoWithTaskSidebarAppClient(r, taskID, func(ctx context.Context, client *http.Client, appURL *url.URL) error {
|
|
status, err := agentapiDoStatusRequest(ctx, client, appURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if status != "stable" {
|
|
return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{
|
|
Message: "Task app is not ready to accept input.",
|
|
Detail: fmt.Sprintf("Status: %s", status),
|
|
})
|
|
}
|
|
|
|
var reqBody struct {
|
|
Content string `json:"content"`
|
|
Type string `json:"type"`
|
|
}
|
|
reqBody.Content = req.Input
|
|
reqBody.Type = "user"
|
|
|
|
req, err := agentapiNewRequest(ctx, http.MethodPost, appURL, "message", reqBody)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{
|
|
Message: "Failed to reach task app endpoint.",
|
|
Detail: err.Error(),
|
|
})
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 128))
|
|
return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{
|
|
Message: "Task app rejected the message.",
|
|
Detail: fmt.Sprintf("Upstream status: %d; Body: %s", resp.StatusCode, body),
|
|
})
|
|
}
|
|
|
|
// {"$schema":"http://localhost:3284/schemas/MessageResponseBody.json","ok":true}
|
|
// {"$schema":"http://localhost:3284/schemas/ErrorModel.json","title":"Unprocessable Entity","status":422,"detail":"validation failed","errors":[{"location":"body.type","value":"oof"}]}
|
|
var respBody map[string]any
|
|
if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil {
|
|
return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{
|
|
Message: "Failed to decode task app response body.",
|
|
Detail: err.Error(),
|
|
})
|
|
}
|
|
|
|
if v, ok := respBody["ok"].(bool); !ok || !v {
|
|
return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{
|
|
Message: "Task app rejected the message.",
|
|
Detail: fmt.Sprintf("Upstream response: %v", respBody),
|
|
})
|
|
}
|
|
|
|
return nil
|
|
}); err != nil {
|
|
httperror.WriteResponseError(ctx, rw, err)
|
|
return
|
|
}
|
|
|
|
rw.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (api *API) taskLogs(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
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
|
|
}
|
|
|
|
var out codersdk.TaskLogsResponse
|
|
if err := api.authAndDoWithTaskSidebarAppClient(r, taskID, func(ctx context.Context, client *http.Client, appURL *url.URL) error {
|
|
req, err := agentapiNewRequest(ctx, http.MethodGet, appURL, "messages", nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{
|
|
Message: "Failed to reach task app endpoint.",
|
|
Detail: err.Error(),
|
|
})
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 128))
|
|
return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{
|
|
Message: "Task app rejected the request.",
|
|
Detail: fmt.Sprintf("Upstream status: %d; Body: %s", resp.StatusCode, body),
|
|
})
|
|
}
|
|
|
|
// {"$schema":"http://localhost:3284/schemas/MessagesResponseBody.json","messages":[]}
|
|
var respBody struct {
|
|
Messages []struct {
|
|
ID int `json:"id"`
|
|
Content string `json:"content"`
|
|
Role string `json:"role"`
|
|
Time time.Time `json:"time"`
|
|
} `json:"messages"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil {
|
|
return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{
|
|
Message: "Failed to decode task app response body.",
|
|
Detail: err.Error(),
|
|
})
|
|
}
|
|
|
|
logs := make([]codersdk.TaskLogEntry, 0, len(respBody.Messages))
|
|
for _, m := range respBody.Messages {
|
|
var typ codersdk.TaskLogType
|
|
switch strings.ToLower(m.Role) {
|
|
case "user":
|
|
typ = codersdk.TaskLogTypeInput
|
|
case "agent":
|
|
typ = codersdk.TaskLogTypeOutput
|
|
default:
|
|
return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{
|
|
Message: "Invalid task app response message role.",
|
|
Detail: fmt.Sprintf(`Expected "user" or "agent", got %q.`, m.Role),
|
|
})
|
|
}
|
|
logs = append(logs, codersdk.TaskLogEntry{
|
|
ID: m.ID,
|
|
Content: m.Content,
|
|
Type: typ,
|
|
Time: m.Time,
|
|
})
|
|
}
|
|
out = codersdk.TaskLogsResponse{Logs: logs}
|
|
return nil
|
|
}); err != nil {
|
|
httperror.WriteResponseError(ctx, rw, err)
|
|
return
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, out)
|
|
}
|
|
|
|
// authAndDoWithTaskSidebarAppClient centralizes the shared logic to:
|
|
//
|
|
// - Fetch the task workspace
|
|
// - Authorize ApplicationConnect on the workspace
|
|
// - Validate the AI task and sidebar app health
|
|
// - Dial the agent and construct an HTTP client to the apps loopback URL
|
|
//
|
|
// The provided callback receives the context, an HTTP client that dials via the
|
|
// agent, and the base app URL (as a value URL) to perform any request.
|
|
func (api *API) authAndDoWithTaskSidebarAppClient(
|
|
r *http.Request,
|
|
taskID uuid.UUID,
|
|
do func(ctx context.Context, client *http.Client, appURL *url.URL) error,
|
|
) error {
|
|
ctx := r.Context()
|
|
|
|
workspaceID := taskID
|
|
workspace, err := api.Database.GetWorkspaceByID(ctx, workspaceID)
|
|
if err != nil {
|
|
if httpapi.Is404Error(err) {
|
|
return httperror.ErrResourceNotFound
|
|
}
|
|
return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching workspace.",
|
|
Detail: err.Error(),
|
|
})
|
|
}
|
|
|
|
// Connecting to applications requires ApplicationConnect on the workspace.
|
|
if !api.Authorize(r, policy.ActionApplicationConnect, workspace) {
|
|
return httperror.ErrResourceNotFound
|
|
}
|
|
|
|
data, err := api.workspaceData(ctx, []database.Workspace{workspace})
|
|
if err != nil {
|
|
return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching workspace resources.",
|
|
Detail: err.Error(),
|
|
})
|
|
}
|
|
if len(data.builds) == 0 || len(data.templates) == 0 {
|
|
return httperror.ErrResourceNotFound
|
|
}
|
|
build := data.builds[0]
|
|
if build.HasAITask == nil || !*build.HasAITask || build.AITaskSidebarAppID == nil || *build.AITaskSidebarAppID == uuid.Nil {
|
|
return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
|
|
Message: "Task is not configured with a sidebar app.",
|
|
})
|
|
}
|
|
|
|
// Find the sidebar app details to get the URL and validate app health.
|
|
sidebarAppID := *build.AITaskSidebarAppID
|
|
agentID, sidebarApp, ok := func() (uuid.UUID, codersdk.WorkspaceApp, bool) {
|
|
for _, res := range build.Resources {
|
|
for _, agent := range res.Agents {
|
|
for _, app := range agent.Apps {
|
|
if app.ID == sidebarAppID {
|
|
return agent.ID, app, true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return uuid.Nil, codersdk.WorkspaceApp{}, false
|
|
}()
|
|
if !ok {
|
|
return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
|
|
Message: "Task sidebar app not found in latest build.",
|
|
})
|
|
}
|
|
|
|
// Return an informative error if the app isn't healthy rather than trying
|
|
// and failing.
|
|
switch sidebarApp.Health {
|
|
case codersdk.WorkspaceAppHealthDisabled:
|
|
// No health check, pass through.
|
|
case codersdk.WorkspaceAppHealthInitializing:
|
|
return httperror.NewResponseError(http.StatusServiceUnavailable, codersdk.Response{
|
|
Message: "Task sidebar app is initializing. Try again shortly.",
|
|
})
|
|
case codersdk.WorkspaceAppHealthUnhealthy:
|
|
return httperror.NewResponseError(http.StatusServiceUnavailable, codersdk.Response{
|
|
Message: "Task sidebar app is unhealthy.",
|
|
})
|
|
}
|
|
|
|
// Build the direct app URL and dial the agent.
|
|
if sidebarApp.URL == "" {
|
|
return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Task sidebar app URL is not configured.",
|
|
})
|
|
}
|
|
parsedURL, err := url.Parse(sidebarApp.URL)
|
|
if err != nil {
|
|
return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error parsing task app URL.",
|
|
Detail: err.Error(),
|
|
})
|
|
}
|
|
if parsedURL.Scheme != "http" {
|
|
return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
|
|
Message: "Only http scheme is supported for direct agent-dial.",
|
|
})
|
|
}
|
|
|
|
dialCtx, dialCancel := context.WithTimeout(ctx, time.Second*30)
|
|
defer dialCancel()
|
|
agentConn, release, err := api.agentProvider.AgentConn(dialCtx, agentID)
|
|
if err != nil {
|
|
return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{
|
|
Message: "Failed to reach task app endpoint.",
|
|
Detail: err.Error(),
|
|
})
|
|
}
|
|
defer release()
|
|
|
|
client := &http.Client{
|
|
Transport: &http.Transport{
|
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
return agentConn.DialContext(ctx, network, addr)
|
|
},
|
|
},
|
|
}
|
|
return do(ctx, client, parsedURL)
|
|
}
|
|
|
|
func agentapiNewRequest(ctx context.Context, method string, appURL *url.URL, appURLPath string, body any) (*http.Request, error) {
|
|
u := *appURL
|
|
u.Path = path.Join(appURL.Path, appURLPath)
|
|
|
|
var bodyReader io.Reader
|
|
if body != nil {
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
return nil, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
|
|
Message: "Failed to marshal task app request body.",
|
|
Detail: err.Error(),
|
|
})
|
|
}
|
|
bodyReader = bytes.NewReader(b)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, method, u.String(), bodyReader)
|
|
if err != nil {
|
|
return nil, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
|
|
Message: "Failed to create task app request.",
|
|
Detail: err.Error(),
|
|
})
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
return req, nil
|
|
}
|
|
|
|
func agentapiDoStatusRequest(ctx context.Context, client *http.Client, appURL *url.URL) (string, error) {
|
|
req, err := agentapiNewRequest(ctx, http.MethodGet, appURL, "status", nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return "", httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{
|
|
Message: "Failed to reach task app endpoint.",
|
|
Detail: err.Error(),
|
|
})
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{
|
|
Message: "Task app status returned an error.",
|
|
Detail: fmt.Sprintf("Status code: %d", resp.StatusCode),
|
|
})
|
|
}
|
|
|
|
// {"$schema":"http://localhost:3284/schemas/StatusResponseBody.json","status":"stable"}
|
|
var respBody struct {
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil {
|
|
return "", httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{
|
|
Message: "Failed to decode task app status response body.",
|
|
Detail: err.Error(),
|
|
})
|
|
}
|
|
|
|
return respBody.Status, nil
|
|
}
|