Files
coder/coderd/aitasks.go
T

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)
}