Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to ourterms of service andprivacy statement. We’ll occasionally send you account related emails.

Already on GitHub?Sign in to your account

Merged
mafredri merged 5 commits intomainfrommafredri/feat-coderd-tasks-logs
Sep 25, 2025
Merged
Show file tree
Hide file tree
Changes fromall commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletionscoderd/aitasks.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -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),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others.Learn more.

👍 this should give a good indication to the user

})
}

// {"$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
Comment on lines +750 to +753
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others.Learn more.

Is there a reason to have these be different? Could we instead usecodersdk.TaskLogTypeUser /codersdk.TaskLogTypeAgent?

Copy link
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others.Learn more.

I'm somewhat indifferent and open to changing this, this is just the last option we discussed/settled on with@DanielleMaywood (correct me if I misremember). It seemed like a good idea to try to tie the concept ofsend input into the logs (input/output) to make the origin clear.

johnstcn reacted with thumbs up emoji
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others.Learn more.

Let's go with this for now. We can adjust if need be.

mafredri reacted with thumbs up emoji
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others.Learn more.

Yeah that was pretty much the reason, I'm also indifferent on what we pick here as well.

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
Expand Down
127 changes: 127 additions & 0 deletionscoderd/aitasks_test.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) {
Expand Down
1 change: 1 addition & 0 deletionscoderd/coderd.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
})
})
Expand Down
71 changes: 71 additions & 0 deletionscodersdk/aitasks.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -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{
Expand All@@ -47,13 +57,19 @@ 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"`
Prompt string `json:"prompt"`
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 {
Expand All@@ -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"
Expand DownExpand Up@@ -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 {
Expand All@@ -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 changes: 18 additions & 0 deletionssite/src/api/typesGenerated.ts
View file
Open in desktop

Some generated files are not rendered by default. Learn more abouthow customized files appear on GitHub.

Loading

[8]ページ先頭

©2009-2025 Movatter.jp