diff --git a/cli/exp_task.go b/cli/exp_task.go index 5c93f3247b..b7a0ada15b 100644 --- a/cli/exp_task.go +++ b/cli/exp_task.go @@ -13,11 +13,12 @@ func (r *RootCmd) tasksCommand() *serpent.Command { return i.Command.HelpHandler(i) }, Children: []*serpent.Command{ - r.taskList(), r.taskCreate(), - r.taskStatus(), r.taskDelete(), + r.taskList(), + r.taskLogs(), r.taskSend(), + r.taskStatus(), }, } return cmd diff --git a/cli/exp_task_logs.go b/cli/exp_task_logs.go new file mode 100644 index 0000000000..c7224e6185 --- /dev/null +++ b/cli/exp_task_logs.go @@ -0,0 +1,73 @@ +package cli + +import ( + "fmt" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/serpent" +) + +func (r *RootCmd) taskLogs() *serpent.Command { + formatter := cliui.NewOutputFormatter( + cliui.TableFormat( + []codersdk.TaskLogEntry{}, + []string{ + "type", + "content", + }, + ), + cliui.JSONFormat(), + ) + + cmd := &serpent.Command{ + Use: "logs ", + Short: "Show a task's logs", + Middleware: serpent.Chain( + serpent.RequireNArgs(1), + ), + Handler: func(inv *serpent.Invocation) error { + client, err := r.InitClient(inv) + if err != nil { + return err + } + + var ( + ctx = inv.Context() + exp = codersdk.NewExperimentalClient(client) + task = inv.Args[0] + taskID uuid.UUID + ) + + if id, err := uuid.Parse(task); err == nil { + taskID = id + } else { + ws, err := namedWorkspace(ctx, client, task) + if err != nil { + return xerrors.Errorf("resolve task %q: %w", task, err) + } + + taskID = ws.ID + } + + logs, err := exp.TaskLogs(ctx, codersdk.Me, taskID) + if err != nil { + return xerrors.Errorf("get task logs: %w", err) + } + + out, err := formatter.Format(ctx, logs.Logs) + if err != nil { + return xerrors.Errorf("format task logs: %w", err) + } + + _, _ = fmt.Fprintln(inv.Stdout, out) + return nil + }, + } + + formatter.AttachOptions(&cmd.Options) + return cmd +} diff --git a/cli/exp_task_logs_test.go b/cli/exp_task_logs_test.go new file mode 100644 index 0000000000..5dc3a99581 --- /dev/null +++ b/cli/exp_task_logs_test.go @@ -0,0 +1,200 @@ +package cli_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +func Test_TaskLogs(t *testing.T) { + t.Parallel() + + var ( + clock = time.Date(2025, 8, 26, 12, 34, 56, 0, time.UTC) + + taskID = uuid.MustParse("11111111-1111-1111-1111-111111111111") + taskName = "task-workspace" + + taskLogs = []codersdk.TaskLogEntry{ + { + ID: 0, + Content: "What is 1 + 1?", + Type: codersdk.TaskLogTypeInput, + Time: clock, + }, + { + ID: 1, + Content: "2", + Type: codersdk.TaskLogTypeOutput, + Time: clock.Add(1 * time.Second), + }, + } + ) + + tests := []struct { + args []string + expectTable string + expectLogs []codersdk.TaskLogEntry + expectError string + handler func(t *testing.T, ctx context.Context) http.HandlerFunc + }{ + { + args: []string{taskName, "--output", "json"}, + expectLogs: taskLogs, + handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case fmt.Sprintf("/api/v2/users/me/workspace/%s", taskName): + httpapi.Write(ctx, w, http.StatusOK, codersdk.Workspace{ + ID: taskID, + }) + case fmt.Sprintf("/api/experimental/tasks/me/%s/logs", taskID.String()): + httpapi.Write(ctx, w, http.StatusOK, codersdk.TaskLogsResponse{ + Logs: taskLogs, + }) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + } + }, + }, + { + args: []string{taskID.String(), "--output", "json"}, + expectLogs: taskLogs, + handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case fmt.Sprintf("/api/experimental/tasks/me/%s/logs", taskID.String()): + httpapi.Write(ctx, w, http.StatusOK, codersdk.TaskLogsResponse{ + Logs: taskLogs, + }) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + } + }, + }, + { + args: []string{taskID.String()}, + expectTable: ` +TYPE CONTENT +input What is 1 + 1? +output 2`, + handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case fmt.Sprintf("/api/experimental/tasks/me/%s/logs", taskID.String()): + httpapi.Write(ctx, w, http.StatusOK, codersdk.TaskLogsResponse{ + Logs: taskLogs, + }) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + } + }, + }, + { + args: []string{"doesnotexist"}, + expectError: httpapi.ResourceNotFoundResponse.Message, + handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v2/users/me/workspace/doesnotexist": + httpapi.ResourceNotFound(w) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + } + }, + }, + { + args: []string{uuid.Nil.String()}, // uuid does not exist + expectError: httpapi.ResourceNotFoundResponse.Message, + handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case fmt.Sprintf("/api/experimental/tasks/me/%s/logs", uuid.Nil.String()): + httpapi.ResourceNotFound(w) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + } + }, + }, + { + args: []string{"err-fetching-logs"}, + expectError: assert.AnError.Error(), + handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v2/users/me/workspace/err-fetching-logs": + httpapi.Write(ctx, w, http.StatusOK, codersdk.Workspace{ + ID: taskID, + }) + case fmt.Sprintf("/api/experimental/tasks/me/%s/logs", taskID.String()): + httpapi.InternalServerError(w, assert.AnError) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + } + }, + }, + } + + for _, tt := range tests { + t.Run(strings.Join(tt.args, ","), func(t *testing.T) { + t.Parallel() + + var ( + ctx = testutil.Context(t, testutil.WaitShort) + srv = httptest.NewServer(tt.handler(t, ctx)) + client = codersdk.New(testutil.MustURL(t, srv.URL)) + args = []string{"exp", "task", "logs"} + stdout strings.Builder + err error + ) + + t.Cleanup(srv.Close) + + inv, root := clitest.New(t, append(args, tt.args...)...) + inv.Stdout = &stdout + inv.Stderr = &stdout + clitest.SetupConfig(t, client, root) + + err = inv.WithContext(ctx).Run() + if tt.expectError == "" { + assert.NoError(t, err) + } else { + assert.ErrorContains(t, err, tt.expectError) + } + + if tt.expectTable != "" { + if diff := tableDiff(tt.expectTable, stdout.String()); diff != "" { + t.Errorf("unexpected output diff (-want +got):\n%s", diff) + } + } + + if tt.expectLogs != nil { + var logs []codersdk.TaskLogEntry + err = json.NewDecoder(strings.NewReader(stdout.String())).Decode(&logs) + require.NoError(t, err) + + assert.Equal(t, tt.expectLogs, logs) + } + }) + } +} diff --git a/codersdk/aitasks.go b/codersdk/aitasks.go index 53c0c06de3..cdc4a625f9 100644 --- a/codersdk/aitasks.go +++ b/codersdk/aitasks.go @@ -261,10 +261,10 @@ const ( // // 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"` + ID int `json:"id" table:"id"` + Content string `json:"content" table:"content"` + Type TaskLogType `json:"type" enum:"input,output" table:"type"` + Time time.Time `json:"time" format:"date-time" table:"time,default_sort"` } // TaskLogsResponse contains the logs for a task.