mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(coderd): add experimental tasks logs endpoint (#19958)
Fixes coder/internal#901
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user