feat(coderd): add experimental tasks logs endpoint (#19958)

Fixes coder/internal#901
This commit is contained in:
Mathias Fredriksson
2025-09-25 15:12:49 +00:00
committed by GitHub
parent 063e8634c7
commit 0bac5a4d5c
5 changed files with 300 additions and 0 deletions
+83
View File
@@ -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
+127
View File
@@ -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) {
+1
View File
@@ -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)
})
})
+71
View File
@@ -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
}
+18
View File
@@ -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;