diff --git a/coderd/aitasks.go b/coderd/aitasks.go index 7161eec510..f56e8de9b4 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -691,6 +691,89 @@ func (api *API) taskSend(rw http.ResponseWriter, r *http.Request) { 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 diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go index 6e51a6917d..1e83c7fb35 100644 --- a/coderd/aitasks_test.go +++ b/coderd/aitasks_test.go @@ -600,6 +600,133 @@ func TestTasks(t *testing.T) { require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) }) }) + + t.Run("Logs", func(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + messageResponse := ` + { + "$schema": "http://localhost:3284/schemas/MessagesResponseBody.json", + "messages": [ + { + "id": 0, + "content": "Welcome, user!", + "role": "agent", + "time": "2025-09-25T10:42:48.751774125Z" + }, + { + "id": 1, + "content": "Hello, agent!", + "role": "user", + "time": "2025-09-25T10:46:42.880996296Z" + }, + { + "id": 2, + "content": "What would you like to work on today?", + "role": "agent", + "time": "2025-09-25T10:46:50.747761102Z" + } + ] + } + ` + + // Fake AgentAPI that returns a couple of messages. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/messages" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + io.WriteString(w, messageResponse) + return + } + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(srv.Close) + + // Template pointing sidebar app to our fake AgentAPI. + authToken := uuid.NewString() + template := createAITemplate(t, client, owner, withSidebarURL(srv.URL), withAgentToken(authToken)) + + // Create task workspace. + ws := coderdtest.CreateWorkspace(t, client, template.ID, func(req *codersdk.CreateWorkspaceRequest) { + req.RichParameterValues = []codersdk.WorkspaceBuildParameter{ + {Name: codersdk.AITaskPromptParameterName, Value: "show logs"}, + } + }) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + + // Start a fake agent. + agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(authToken)) + _ = agenttest.New(t, client.URL, authToken, func(o *agent.Options) { + o.Client = agentClient + }) + coderdtest.NewWorkspaceAgentWaiter(t, client, ws.ID).WaitFor(coderdtest.AgentsReady) + + // Omit sidebar app health as undefined is OK. + + // Fetch logs. + exp := codersdk.NewExperimentalClient(client) + resp, err := exp.TaskLogs(ctx, "me", ws.ID) + require.NoError(t, err) + require.Len(t, resp.Logs, 3) + assert.Equal(t, 0, resp.Logs[0].ID) + assert.Equal(t, codersdk.TaskLogTypeOutput, resp.Logs[0].Type) + assert.Equal(t, "Welcome, user!", resp.Logs[0].Content) + + assert.Equal(t, 1, resp.Logs[1].ID) + assert.Equal(t, codersdk.TaskLogTypeInput, resp.Logs[1].Type) + assert.Equal(t, "Hello, agent!", resp.Logs[1].Content) + + assert.Equal(t, 2, resp.Logs[2].ID) + assert.Equal(t, codersdk.TaskLogTypeOutput, resp.Logs[2].Type) + assert.Equal(t, "What would you like to work on today?", resp.Logs[2].Content) + }) + + t.Run("UpstreamError", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitShort) + + // Fake AgentAPI that returns 500 for messages. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, "boom") + })) + t.Cleanup(srv.Close) + + authToken := uuid.NewString() + template := createAITemplate(t, client, owner, withSidebarURL(srv.URL), withAgentToken(authToken)) + ws := coderdtest.CreateWorkspace(t, client, template.ID, func(req *codersdk.CreateWorkspaceRequest) { + req.RichParameterValues = []codersdk.WorkspaceBuildParameter{ + {Name: codersdk.AITaskPromptParameterName, Value: "show logs"}, + } + }) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + + // Start fake agent. + agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(authToken)) + _ = agenttest.New(t, client.URL, authToken, func(o *agent.Options) { + o.Client = agentClient + }) + coderdtest.NewWorkspaceAgentWaiter(t, client, ws.ID).WaitFor(coderdtest.AgentsReady) + + exp := codersdk.NewExperimentalClient(client) + _, err := exp.TaskLogs(ctx, "me", ws.ID) + + var sdkErr *codersdk.Error + require.Error(t, err) + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadGateway, sdkErr.StatusCode()) + }) + }) } func TestTasksCreate(t *testing.T) { diff --git a/coderd/coderd.go b/coderd/coderd.go index b59ad85a48..57dea0f85f 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1020,6 +1020,7 @@ func New(options *Options) *API { r.Get("/{id}", api.taskGet) r.Delete("/{id}", api.taskDelete) r.Post("/{id}/send", api.taskSend) + r.Get("/{id}/logs", api.taskLogs) r.Post("/", api.tasksCreate) }) }) diff --git a/codersdk/aitasks.go b/codersdk/aitasks.go index f11b5a7d1b..bb410e1309 100644 --- a/codersdk/aitasks.go +++ b/codersdk/aitasks.go @@ -9,18 +9,28 @@ import ( "time" "github.com/google/uuid" + "golang.org/x/xerrors" "github.com/coder/terraform-provider-coder/v2/provider" ) +// AITaskPromptParameterName is the name of the parameter used to pass prompts +// to AI tasks. +// +// Experimental: This value is experimental and may change in the future. const AITaskPromptParameterName = provider.TaskPromptParameterName +// AITasksPromptsResponse represents the response from the AITaskPrompts method. +// +// Experimental: This method is experimental and may change in the future. type AITasksPromptsResponse struct { // Prompts is a map of workspace build IDs to prompts. Prompts map[string]string `json:"prompts"` } // AITaskPrompts returns prompts for multiple workspace builds by their IDs. +// +// Experimental: This method is experimental and may change in the future. func (c *ExperimentalClient) AITaskPrompts(ctx context.Context, buildIDs []uuid.UUID) (AITasksPromptsResponse, error) { if len(buildIDs) == 0 { return AITasksPromptsResponse{ @@ -47,6 +57,9 @@ func (c *ExperimentalClient) AITaskPrompts(ctx context.Context, buildIDs []uuid. return prompts, json.NewDecoder(res.Body).Decode(&prompts) } +// CreateTaskRequest represents the request to create a new task. +// +// Experimental: This type is experimental and may change in the future. type CreateTaskRequest struct { TemplateVersionID uuid.UUID `json:"template_version_id" format:"uuid"` TemplateVersionPresetID uuid.UUID `json:"template_version_preset_id,omitempty" format:"uuid"` @@ -54,6 +67,9 @@ type CreateTaskRequest struct { Name string `json:"name,omitempty"` } +// CreateTask creates a new task. +// +// Experimental: This method is experimental and may change in the future. func (c *ExperimentalClient) CreateTask(ctx context.Context, user string, request CreateTaskRequest) (Task, error) { res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/experimental/tasks/%s", user), request) if err != nil { @@ -78,6 +94,7 @@ func (c *ExperimentalClient) CreateTask(ctx context.Context, user string, reques // Experimental: This type is experimental and may change in the future. type TaskState string +// TaskState enums. const ( TaskStateWorking TaskState = "working" TaskStateIdle TaskState = "idle" @@ -208,11 +225,15 @@ func (c *ExperimentalClient) DeleteTask(ctx context.Context, user string, id uui } // TaskSendRequest is used to send task input to the tasks sidebar app. +// +// Experimental: This type is experimental and may change in the future. type TaskSendRequest struct { Input string `json:"input"` } // TaskSend submits task input to the tasks sidebar app. +// +// Experimental: This method is experimental and may change in the future. func (c *ExperimentalClient) TaskSend(ctx context.Context, user string, id uuid.UUID, req TaskSendRequest) error { res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/experimental/tasks/%s/%s/send", user, id.String()), req) if err != nil { @@ -224,3 +245,53 @@ func (c *ExperimentalClient) TaskSend(ctx context.Context, user string, id uuid. } return nil } + +// TaskLogType indicates the source of a task log entry. +// +// Experimental: This type is experimental and may change in the future. +type TaskLogType string + +// TaskLogType enums. +const ( + TaskLogTypeInput TaskLogType = "input" + TaskLogTypeOutput TaskLogType = "output" +) + +// TaskLogEntry represents a single log entry for a task. +// +// Experimental: This type is experimental and may change in the future. +type TaskLogEntry struct { + ID int `json:"id"` + Content string `json:"content"` + Type TaskLogType `json:"type" enum:"input,output"` + Time time.Time `json:"time" format:"date-time"` +} + +// TaskLogsResponse contains the logs for a task. +// +// Experimental: This type is experimental and may change in the future. +type TaskLogsResponse struct { + Logs []TaskLogEntry `json:"logs"` +} + +// TaskLogs retrieves logs from the task's sidebar app via the experimental API. +// +// Experimental: This method is experimental and may change in the future. +func (c *ExperimentalClient) TaskLogs(ctx context.Context, user string, id uuid.UUID) (TaskLogsResponse, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/tasks/%s/%s/logs", user, id.String()), nil) + if err != nil { + return TaskLogsResponse{}, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return TaskLogsResponse{}, ReadBodyAsError(res) + } + + var logs TaskLogsResponse + if err := json.NewDecoder(res.Body).Decode(&logs); err != nil { + return TaskLogsResponse{}, xerrors.Errorf("decoding task logs response: %w", err) + } + + return logs, nil +} diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 8cbc3a20e6..f7bac87970 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2883,6 +2883,24 @@ export interface Task { readonly updated_at: string; } +// From codersdk/aitasks.go +export interface TaskLogEntry { + readonly id: number; + readonly content: string; + readonly type: TaskLogType; + readonly time: string; +} + +// From codersdk/aitasks.go +export type TaskLogType = "input" | "output"; + +export const TaskLogTypes: TaskLogType[] = ["input", "output"]; + +// From codersdk/aitasks.go +export interface TaskLogsResponse { + readonly logs: readonly TaskLogEntry[]; +} + // From codersdk/aitasks.go export interface TaskSendRequest { readonly input: string;