mirror of
https://github.com/coder/coder.git
synced 2026-06-03 13:08:25 +00:00
82945cfb16
Updates coder/internal#976
938 lines
30 KiB
Go
938 lines
30 KiB
Go
package coderd
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"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"
|
|
|
|
aiagentapi "github.com/coder/agentapi-sdk-go"
|
|
)
|
|
|
|
// 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,
|
|
})
|
|
}
|
|
|
|
// @Summary Create a new AI task
|
|
// @Description: EXPERIMENTAL: this endpoint is experimental and not guaranteed to be stable.
|
|
// @ID create-task
|
|
// @Security CoderSessionToken
|
|
// @Tags Experimental
|
|
// @Param user path string true "Username, user ID, or 'me' for the authenticated user"
|
|
// @Param request body codersdk.CreateTaskRequest true "Create task request"
|
|
// @Success 201 {object} codersdk.Task
|
|
// @Router /api/experimental/tasks/{user} [post]
|
|
//
|
|
// EXPERIMENTAL: This endpoint is experimental and not guaranteed to be stable.
|
|
// This endpoint creates a new task for the given user.
|
|
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,
|
|
}
|
|
}
|
|
}
|
|
|
|
var appID uuid.NullUUID
|
|
if ws.LatestBuild.AITaskSidebarAppID != nil {
|
|
appID = uuid.NullUUID{
|
|
Valid: true,
|
|
UUID: *ws.LatestBuild.AITaskSidebarAppID,
|
|
}
|
|
}
|
|
|
|
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},
|
|
WorkspaceBuildNumber: ws.LatestBuild.BuildNumber,
|
|
WorkspaceAgentID: taskAgentID,
|
|
WorkspaceAgentLifecycle: taskAgentLifecycle,
|
|
WorkspaceAgentHealth: taskAgentHealth,
|
|
WorkspaceAppID: appID,
|
|
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"`
|
|
}
|
|
|
|
// @Summary List AI tasks
|
|
// @Description: EXPERIMENTAL: this endpoint is experimental and not guaranteed to be stable.
|
|
// @ID list-tasks
|
|
// @Security CoderSessionToken
|
|
// @Tags Experimental
|
|
// @Param q query string false "Search query for filtering tasks"
|
|
// @Param after_id query string false "Return tasks after this ID for pagination"
|
|
// @Param limit query int false "Maximum number of tasks to return" minimum(1) maximum(100) default(25)
|
|
// @Param offset query int false "Offset for pagination" minimum(0) default(0)
|
|
// @Success 200 {object} coderd.tasksListResponse
|
|
// @Router /api/experimental/tasks [get]
|
|
//
|
|
// EXPERIMENTAL: This endpoint is experimental and not guaranteed to be stable.
|
|
// 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),
|
|
})
|
|
}
|
|
|
|
// @Summary Get AI task by ID
|
|
// @Description: EXPERIMENTAL: this endpoint is experimental and not guaranteed to be stable.
|
|
// @ID get-task
|
|
// @Security CoderSessionToken
|
|
// @Tags Experimental
|
|
// @Param user path string true "Username, user ID, or 'me' for the authenticated user"
|
|
// @Param id path string true "Task ID" format(uuid)
|
|
// @Success 200 {object} codersdk.Task
|
|
// @Router /api/experimental/tasks/{user}/{id} [get]
|
|
//
|
|
// EXPERIMENTAL: This endpoint is experimental and not guaranteed to be stable.
|
|
// 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])
|
|
}
|
|
|
|
// @Summary Delete AI task by ID
|
|
// @Description: EXPERIMENTAL: this endpoint is experimental and not guaranteed to be stable.
|
|
// @ID delete-task
|
|
// @Security CoderSessionToken
|
|
// @Tags Experimental
|
|
// @Param user path string true "Username, user ID, or 'me' for the authenticated user"
|
|
// @Param id path string true "Task ID" format(uuid)
|
|
// @Success 202 "Task deletion initiated"
|
|
// @Router /api/experimental/tasks/{user}/{id} [delete]
|
|
//
|
|
// EXPERIMENTAL: This endpoint is experimental and not guaranteed to be stable.
|
|
// 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)
|
|
}
|
|
|
|
// @Summary Send input to AI task
|
|
// @Description: EXPERIMENTAL: this endpoint is experimental and not guaranteed to be stable.
|
|
// @ID send-task-input
|
|
// @Security CoderSessionToken
|
|
// @Tags Experimental
|
|
// @Param user path string true "Username, user ID, or 'me' for the authenticated user"
|
|
// @Param id path string true "Task ID" format(uuid)
|
|
// @Param request body codersdk.TaskSendRequest true "Task input request"
|
|
// @Success 204 "Input sent successfully"
|
|
// @Router /api/experimental/tasks/{user}/{id}/send [post]
|
|
//
|
|
// EXPERIMENTAL: This endpoint is experimental and not guaranteed to be stable.
|
|
// 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 {
|
|
agentAPIClient, err := aiagentapi.NewClient(appURL.String(), aiagentapi.WithHTTPClient(client))
|
|
if err != nil {
|
|
return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{
|
|
Message: "Failed to create agentapi client.",
|
|
Detail: err.Error(),
|
|
})
|
|
}
|
|
|
|
statusResp, err := agentAPIClient.GetStatus(ctx)
|
|
if err != nil {
|
|
return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{
|
|
Message: "Failed to get status from task app.",
|
|
Detail: err.Error(),
|
|
})
|
|
}
|
|
|
|
if statusResp.Status != aiagentapi.StatusStable {
|
|
return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{
|
|
Message: "Task app is not ready to accept input.",
|
|
Detail: fmt.Sprintf("Status: %s", statusResp.Status),
|
|
})
|
|
}
|
|
|
|
_, err = agentAPIClient.PostMessage(ctx, aiagentapi.PostMessageParams{
|
|
Content: req.Input,
|
|
Type: aiagentapi.MessageTypeUser,
|
|
})
|
|
if err != nil {
|
|
return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{
|
|
Message: "Task app rejected the message.",
|
|
Detail: err.Error(),
|
|
})
|
|
}
|
|
|
|
return nil
|
|
}); err != nil {
|
|
httperror.WriteResponseError(ctx, rw, err)
|
|
return
|
|
}
|
|
|
|
rw.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// @Summary Get AI task logs
|
|
// @Description: EXPERIMENTAL: this endpoint is experimental and not guaranteed to be stable.
|
|
// @ID get-task-logs
|
|
// @Security CoderSessionToken
|
|
// @Tags Experimental
|
|
// @Param user path string true "Username, user ID, or 'me' for the authenticated user"
|
|
// @Param id path string true "Task ID" format(uuid)
|
|
// @Success 200 {object} codersdk.TaskLogsResponse
|
|
// @Router /api/experimental/tasks/{user}/{id}/logs [get]
|
|
//
|
|
// EXPERIMENTAL: This endpoint is experimental and not guaranteed to be stable.
|
|
// taskLogs reads task output by dialing the agent directly over the tailnet.
|
|
// We enforce ApplicationConnect RBAC on the workspace and validate the sidebar app health.
|
|
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 {
|
|
agentAPIClient, err := aiagentapi.NewClient(appURL.String(), aiagentapi.WithHTTPClient(client))
|
|
if err != nil {
|
|
return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{
|
|
Message: "Failed to create agentapi client.",
|
|
Detail: err.Error(),
|
|
})
|
|
}
|
|
|
|
messagesResp, err := agentAPIClient.GetMessages(ctx)
|
|
if err != nil {
|
|
return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{
|
|
Message: "Failed to get messages from task app.",
|
|
Detail: err.Error(),
|
|
})
|
|
}
|
|
|
|
logs := make([]codersdk.TaskLogEntry, 0, len(messagesResp.Messages))
|
|
for _, m := range messagesResp.Messages {
|
|
var typ codersdk.TaskLogType
|
|
switch m.Role {
|
|
case aiagentapi.RoleUser:
|
|
typ = codersdk.TaskLogTypeInput
|
|
case aiagentapi.RoleAgent:
|
|
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: int(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)
|
|
}
|