Skip to content

Commit 0bac5a4

Browse files
authored
feat(coderd): add experimental tasks logs endpoint (#19958)
Fixes coder/internal#901
1 parent 063e863 commit 0bac5a4

5 files changed

Lines changed: 300 additions & 0 deletions

File tree

‎coderd/aitasks.go‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -691,6 +691,89 @@ func (api *API) taskSend(rw http.ResponseWriter, r *http.Request) {
691691
rw.WriteHeader(http.StatusNoContent)
692692
}
693693

694+
func (api *API) taskLogs(rw http.ResponseWriter, r *http.Request) {
695+
ctx := r.Context()
696+
697+
idStr := chi.URLParam(r, "id")
698+
taskID, err := uuid.Parse(idStr)
699+
if err != nil {
700+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
701+
Message: fmt.Sprintf("Invalid UUID %q for task ID.", idStr),
702+
})
703+
return
704+
}
705+
706+
var out codersdk.TaskLogsResponse
707+
if err := api.authAndDoWithTaskSidebarAppClient(r, taskID, func(ctx context.Context, client *http.Client, appURL *url.URL) error {
708+
req, err := agentapiNewRequest(ctx, http.MethodGet, appURL, "messages", nil)
709+
if err != nil {
710+
return err
711+
}
712+
713+
resp, err := client.Do(req)
714+
if err != nil {
715+
return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{
716+
Message: "Failed to reach task app endpoint.",
717+
Detail: err.Error(),
718+
})
719+
}
720+
defer resp.Body.Close()
721+
722+
if resp.StatusCode != http.StatusOK {
723+
body, _ := io.ReadAll(io.LimitReader(resp.Body, 128))
724+
return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{
725+
Message: "Task app rejected the request.",
726+
Detail: fmt.Sprintf("Upstream status: %d; Body: %s", resp.StatusCode, body),
727+
})
728+
}
729+
730+
// {"$schema":"http://localhost:3284/schemas/MessagesResponseBody.json","messages":[]}
731+
var respBody struct {
732+
Messages []struct {
733+
ID int `json:"id"`
734+
Content string `json:"content"`
735+
Role string `json:"role"`
736+
Time time.Time `json:"time"`
737+
} `json:"messages"`
738+
}
739+
if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil {
740+
return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{
741+
Message: "Failed to decode task app response body.",
742+
Detail: err.Error(),
743+
})
744+
}
745+
746+
logs := make([]codersdk.TaskLogEntry, 0, len(respBody.Messages))
747+
for _, m := range respBody.Messages {
748+
var typ codersdk.TaskLogType
749+
switch strings.ToLower(m.Role) {
750+
case "user":
751+
typ = codersdk.TaskLogTypeInput
752+
case "agent":
753+
typ = codersdk.TaskLogTypeOutput
754+
default:
755+
return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{
756+
Message: "Invalid task app response message role.",
757+
Detail: fmt.Sprintf(`Expected "user" or "agent", got %q.`, m.Role),
758+
})
759+
}
760+
logs = append(logs, codersdk.TaskLogEntry{
761+
ID: m.ID,
762+
Content: m.Content,
763+
Type: typ,
764+
Time: m.Time,
765+
})
766+
}
767+
out = codersdk.TaskLogsResponse{Logs: logs}
768+
return nil
769+
}); err != nil {
770+
httperror.WriteResponseError(ctx, rw, err)
771+
return
772+
}
773+
774+
httpapi.Write(ctx, rw, http.StatusOK, out)
775+
}
776+
694777
// authAndDoWithTaskSidebarAppClient centralizes the shared logic to:
695778
//
696779
// - Fetch the task workspace

‎coderd/aitasks_test.go‎

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,133 @@ func TestTasks(t *testing.T) {
600600
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
601601
})
602602
})
603+
604+
t.Run("Logs", func(t *testing.T) {
605+
t.Parallel()
606+
607+
t.Run("OK", func(t *testing.T) {
608+
t.Parallel()
609+
610+
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
611+
owner := coderdtest.CreateFirstUser(t, client)
612+
ctx := testutil.Context(t, testutil.WaitLong)
613+
614+
messageResponse := `
615+
{
616+
"$schema": "http://localhost:3284/schemas/MessagesResponseBody.json",
617+
"messages": [
618+
{
619+
"id": 0,
620+
"content": "Welcome, user!",
621+
"role": "agent",
622+
"time": "2025-09-25T10:42:48.751774125Z"
623+
},
624+
{
625+
"id": 1,
626+
"content": "Hello, agent!",
627+
"role": "user",
628+
"time": "2025-09-25T10:46:42.880996296Z"
629+
},
630+
{
631+
"id": 2,
632+
"content": "What would you like to work on today?",
633+
"role": "agent",
634+
"time": "2025-09-25T10:46:50.747761102Z"
635+
}
636+
]
637+
}
638+
`
639+
640+
// Fake AgentAPI that returns a couple of messages.
641+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
642+
if r.Method == http.MethodGet && r.URL.Path == "/messages" {
643+
w.Header().Set("Content-Type", "application/json")
644+
w.WriteHeader(http.StatusOK)
645+
io.WriteString(w, messageResponse)
646+
return
647+
}
648+
w.WriteHeader(http.StatusNotFound)
649+
}))
650+
t.Cleanup(srv.Close)
651+
652+
// Template pointing sidebar app to our fake AgentAPI.
653+
authToken := uuid.NewString()
654+
template := createAITemplate(t, client, owner, withSidebarURL(srv.URL), withAgentToken(authToken))
655+
656+
// Create task workspace.
657+
ws := coderdtest.CreateWorkspace(t, client, template.ID, func(req *codersdk.CreateWorkspaceRequest) {
658+
req.RichParameterValues = []codersdk.WorkspaceBuildParameter{
659+
{Name: codersdk.AITaskPromptParameterName, Value: "show logs"},
660+
}
661+
})
662+
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
663+
664+
// Start a fake agent.
665+
agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(authToken))
666+
_ = agenttest.New(t, client.URL, authToken, func(o *agent.Options) {
667+
o.Client = agentClient
668+
})
669+
coderdtest.NewWorkspaceAgentWaiter(t, client, ws.ID).WaitFor(coderdtest.AgentsReady)
670+
671+
// Omit sidebar app health as undefined is OK.
672+
673+
// Fetch logs.
674+
exp := codersdk.NewExperimentalClient(client)
675+
resp, err := exp.TaskLogs(ctx, "me", ws.ID)
676+
require.NoError(t, err)
677+
require.Len(t, resp.Logs, 3)
678+
assert.Equal(t, 0, resp.Logs[0].ID)
679+
assert.Equal(t, codersdk.TaskLogTypeOutput, resp.Logs[0].Type)
680+
assert.Equal(t, "Welcome, user!", resp.Logs[0].Content)
681+
682+
assert.Equal(t, 1, resp.Logs[1].ID)
683+
assert.Equal(t, codersdk.TaskLogTypeInput, resp.Logs[1].Type)
684+
assert.Equal(t, "Hello, agent!", resp.Logs[1].Content)
685+
686+
assert.Equal(t, 2, resp.Logs[2].ID)
687+
assert.Equal(t, codersdk.TaskLogTypeOutput, resp.Logs[2].Type)
688+
assert.Equal(t, "What would you like to work on today?", resp.Logs[2].Content)
689+
})
690+
691+
t.Run("UpstreamError", func(t *testing.T) {
692+
t.Parallel()
693+
694+
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
695+
owner := coderdtest.CreateFirstUser(t, client)
696+
ctx := testutil.Context(t, testutil.WaitShort)
697+
698+
// Fake AgentAPI that returns 500 for messages.
699+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
700+
w.WriteHeader(http.StatusInternalServerError)
701+
_, _ = io.WriteString(w, "boom")
702+
}))
703+
t.Cleanup(srv.Close)
704+
705+
authToken := uuid.NewString()
706+
template := createAITemplate(t, client, owner, withSidebarURL(srv.URL), withAgentToken(authToken))
707+
ws := coderdtest.CreateWorkspace(t, client, template.ID, func(req *codersdk.CreateWorkspaceRequest) {
708+
req.RichParameterValues = []codersdk.WorkspaceBuildParameter{
709+
{Name: codersdk.AITaskPromptParameterName, Value: "show logs"},
710+
}
711+
})
712+
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
713+
714+
// Start fake agent.
715+
agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(authToken))
716+
_ = agenttest.New(t, client.URL, authToken, func(o *agent.Options) {
717+
o.Client = agentClient
718+
})
719+
coderdtest.NewWorkspaceAgentWaiter(t, client, ws.ID).WaitFor(coderdtest.AgentsReady)
720+
721+
exp := codersdk.NewExperimentalClient(client)
722+
_, err := exp.TaskLogs(ctx, "me", ws.ID)
723+
724+
var sdkErr *codersdk.Error
725+
require.Error(t, err)
726+
require.ErrorAs(t, err, &sdkErr)
727+
require.Equal(t, http.StatusBadGateway, sdkErr.StatusCode())
728+
})
729+
})
603730
}
604731

605732
func TestTasksCreate(t *testing.T) {

‎coderd/coderd.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,6 +1020,7 @@ func New(options *Options) *API {
10201020
r.Get("/{id}", api.taskGet)
10211021
r.Delete("/{id}", api.taskDelete)
10221022
r.Post("/{id}/send", api.taskSend)
1023+
r.Get("/{id}/logs", api.taskLogs)
10231024
r.Post("/", api.tasksCreate)
10241025
})
10251026
})

‎codersdk/aitasks.go‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,28 @@ import (
99
"time"
1010

1111
"github.com/google/uuid"
12+
"golang.org/x/xerrors"
1213

1314
"github.com/coder/terraform-provider-coder/v2/provider"
1415
)
1516

17+
// AITaskPromptParameterName is the name of the parameter used to pass prompts
18+
// to AI tasks.
19+
//
20+
// Experimental: This value is experimental and may change in the future.
1621
const AITaskPromptParameterName = provider.TaskPromptParameterName
1722

23+
// AITasksPromptsResponse represents the response from the AITaskPrompts method.
24+
//
25+
// Experimental: This method is experimental and may change in the future.
1826
type AITasksPromptsResponse struct {
1927
// Prompts is a map of workspace build IDs to prompts.
2028
Prompts map[string]string `json:"prompts"`
2129
}
2230

2331
// AITaskPrompts returns prompts for multiple workspace builds by their IDs.
32+
//
33+
// Experimental: This method is experimental and may change in the future.
2434
func (c *ExperimentalClient) AITaskPrompts(ctx context.Context, buildIDs []uuid.UUID) (AITasksPromptsResponse, error) {
2535
if len(buildIDs) == 0 {
2636
return AITasksPromptsResponse{
@@ -47,13 +57,19 @@ func (c *ExperimentalClient) AITaskPrompts(ctx context.Context, buildIDs []uuid.
4757
return prompts, json.NewDecoder(res.Body).Decode(&prompts)
4858
}
4959

60+
// CreateTaskRequest represents the request to create a new task.
61+
//
62+
// Experimental: This type is experimental and may change in the future.
5063
type CreateTaskRequest struct {
5164
TemplateVersionID uuid.UUID `json:"template_version_id" format:"uuid"`
5265
TemplateVersionPresetID uuid.UUID `json:"template_version_preset_id,omitempty" format:"uuid"`
5366
Prompt string `json:"prompt"`
5467
Name string `json:"name,omitempty"`
5568
}
5669

70+
// CreateTask creates a new task.
71+
//
72+
// Experimental: This method is experimental and may change in the future.
5773
func (c *ExperimentalClient) CreateTask(ctx context.Context, user string, request CreateTaskRequest) (Task, error) {
5874
res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/experimental/tasks/%s", user), request)
5975
if err != nil {
@@ -78,6 +94,7 @@ func (c *ExperimentalClient) CreateTask(ctx context.Context, user string, reques
7894
// Experimental: This type is experimental and may change in the future.
7995
type TaskState string
8096

97+
// TaskState enums.
8198
const (
8299
TaskStateWorking TaskState = "working"
83100
TaskStateIdle TaskState = "idle"
@@ -208,11 +225,15 @@ func (c *ExperimentalClient) DeleteTask(ctx context.Context, user string, id uui
208225
}
209226

210227
// TaskSendRequest is used to send task input to the tasks sidebar app.
228+
//
229+
// Experimental: This type is experimental and may change in the future.
211230
type TaskSendRequest struct {
212231
Input string `json:"input"`
213232
}
214233

215234
// TaskSend submits task input to the tasks sidebar app.
235+
//
236+
// Experimental: This method is experimental and may change in the future.
216237
func (c *ExperimentalClient) TaskSend(ctx context.Context, user string, id uuid.UUID, req TaskSendRequest) error {
217238
res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/experimental/tasks/%s/%s/send", user, id.String()), req)
218239
if err != nil {
@@ -224,3 +245,53 @@ func (c *ExperimentalClient) TaskSend(ctx context.Context, user string, id uuid.
224245
}
225246
return nil
226247
}
248+
249+
// TaskLogType indicates the source of a task log entry.
250+
//
251+
// Experimental: This type is experimental and may change in the future.
252+
type TaskLogType string
253+
254+
// TaskLogType enums.
255+
const (
256+
TaskLogTypeInput TaskLogType = "input"
257+
TaskLogTypeOutput TaskLogType = "output"
258+
)
259+
260+
// TaskLogEntry represents a single log entry for a task.
261+
//
262+
// Experimental: This type is experimental and may change in the future.
263+
type TaskLogEntry struct {
264+
ID int `json:"id"`
265+
Content string `json:"content"`
266+
Type TaskLogType `json:"type" enum:"input,output"`
267+
Time time.Time `json:"time" format:"date-time"`
268+
}
269+
270+
// TaskLogsResponse contains the logs for a task.
271+
//
272+
// Experimental: This type is experimental and may change in the future.
273+
type TaskLogsResponse struct {
274+
Logs []TaskLogEntry `json:"logs"`
275+
}
276+
277+
// TaskLogs retrieves logs from the task's sidebar app via the experimental API.
278+
//
279+
// Experimental: This method is experimental and may change in the future.
280+
func (c *ExperimentalClient) TaskLogs(ctx context.Context, user string, id uuid.UUID) (TaskLogsResponse, error) {
281+
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/tasks/%s/%s/logs", user, id.String()), nil)
282+
if err != nil {
283+
return TaskLogsResponse{}, err
284+
}
285+
defer res.Body.Close()
286+
287+
if res.StatusCode != http.StatusOK {
288+
return TaskLogsResponse{}, ReadBodyAsError(res)
289+
}
290+
291+
var logs TaskLogsResponse
292+
if err := json.NewDecoder(res.Body).Decode(&logs); err != nil {
293+
return TaskLogsResponse{}, xerrors.Errorf("decoding task logs response: %w", err)
294+
}
295+
296+
return logs, nil
297+
}

‎site/src/api/typesGenerated.ts‎

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)