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

Commitbb399d7

Browse files
committed
feat(coderd): add tasks logs endpoint
Fixescoder/internal#901
1 parent4103faa commitbb399d7

File tree

4 files changed

+283
-0
lines changed

4 files changed

+283
-0
lines changed

‎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+
iferr!=nil {
700+
httperror.WriteResponseError(ctx,rw,httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
701+
Message:fmt.Sprintf("Invalid UUID %q for task ID.",idStr),
702+
}))
703+
return
704+
}
705+
706+
varout codersdk.TaskLogsResponse
707+
iferr:=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+
iferr!=nil {
710+
returnerr
711+
}
712+
713+
resp,err:=client.Do(req)
714+
iferr!=nil {
715+
returnhttperror.NewResponseError(http.StatusBadGateway, codersdk.Response{
716+
Message:"Failed to reach task app endpoint.",
717+
Detail:err.Error(),
718+
})
719+
}
720+
deferresp.Body.Close()
721+
722+
ifresp.StatusCode!=http.StatusOK {
723+
body,_:=io.ReadAll(io.LimitReader(resp.Body,128))
724+
returnhttperror.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+
varrespBodystruct {
732+
Messages []struct {
733+
IDint`json:"id"`
734+
Contentstring`json:"content"`
735+
Rolestring`json:"role"`
736+
Time time.Time`json:"time"`
737+
}`json:"messages"`
738+
}
739+
iferr:=json.NewDecoder(resp.Body).Decode(&respBody);err!=nil {
740+
returnhttperror.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:=rangerespBody.Messages {
748+
vartyp codersdk.TaskLogType
749+
switchstrings.ToLower(m.Role) {
750+
case"user":
751+
typ=codersdk.TaskLogTypeInput
752+
case"agent":
753+
typ=codersdk.TaskLogTypeOutput
754+
default:
755+
returnhttperror.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+
returnnil
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: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package coderd_test
22

33
import (
44
"fmt"
5+
"io"
56
"net/http"
67
"net/http/httptest"
78
"testing"
@@ -599,6 +600,133 @@ func TestTasks(t *testing.T) {
599600
require.Equal(t,http.StatusBadRequest,sdkErr.StatusCode())
600601
})
601602
})
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+
ifr.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+
varsdkErr*codersdk.Error
725+
require.Error(t,err)
726+
require.ErrorAs(t,err,&sdkErr)
727+
require.Equal(t,http.StatusBadGateway,sdkErr.StatusCode())
728+
})
729+
})
602730
}
603731

604732
funcTestTasksCreate(t*testing.T) {

‎coderd/coderd.go‎

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

‎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
constAITaskPromptParameterName=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
typeAITasksPromptsResponsestruct {
1927
// Prompts is a map of workspace build IDs to prompts.
2028
Promptsmap[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
iflen(buildIDs)==0 {
2636
returnAITasksPromptsResponse{
@@ -47,13 +57,19 @@ func (c *ExperimentalClient) AITaskPrompts(ctx context.Context, buildIDs []uuid.
4757
returnprompts,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
typeCreateTaskRequeststruct {
5164
TemplateVersionID uuid.UUID`json:"template_version_id" format:"uuid"`
5265
TemplateVersionPresetID uuid.UUID`json:"template_version_preset_id,omitempty" format:"uuid"`
5366
Promptstring`json:"prompt"`
5467
Namestring`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,userstring,requestCreateTaskRequest) (Task,error) {
5874
res,err:=c.Request(ctx,http.MethodPost,fmt.Sprintf("/api/experimental/tasks/%s",user),request)
5975
iferr!=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
typeTaskStatestring
8096

97+
// TaskState enums.
8198
const (
8299
TaskStateWorkingTaskState="working"
83100
TaskStateIdleTaskState="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
typeTaskSendRequeststruct {
212231
Inputstring`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,userstring,id uuid.UUID,reqTaskSendRequest)error {
217238
res,err:=c.Request(ctx,http.MethodPost,fmt.Sprintf("/api/experimental/tasks/%s/%s/send",user,id.String()),req)
218239
iferr!=nil {
@@ -224,3 +245,53 @@ func (c *ExperimentalClient) TaskSend(ctx context.Context, user string, id uuid.
224245
}
225246
returnnil
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+
typeTaskLogTypestring
253+
254+
// TaskLogType enums.
255+
const (
256+
TaskLogTypeInputTaskLogType="input"
257+
TaskLogTypeOutputTaskLogType="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+
typeTaskLogEntrystruct {
264+
IDint`json:"id"`
265+
Contentstring`json:"content"`
266+
TypeTaskLogType`json:"type"`// maps from agentapi role
267+
Time time.Time`json:"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+
typeTaskLogsResponsestruct {
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,userstring,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+
iferr!=nil {
283+
returnTaskLogsResponse{},err
284+
}
285+
deferres.Body.Close()
286+
287+
ifres.StatusCode!=http.StatusOK {
288+
returnTaskLogsResponse{},ReadBodyAsError(res)
289+
}
290+
291+
varlogsTaskLogsResponse
292+
iferr:=json.NewDecoder(res.Body).Decode(&logs);err!=nil {
293+
returnTaskLogsResponse{},xerrors.Errorf("decoding task logs response: %w",err)
294+
}
295+
296+
returnlogs,nil
297+
}

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp