From 63c1325ad5f52dbce8e9193563a91e1c3962049f Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 26 Aug 2025 15:24:42 +0100 Subject: [PATCH 1/6] feat(cli): add exp task create command (#19492) Partially implements https://github.com/coder/internal/issues/893 This isn't the full implementation of `coder exp tasks create` as defined in the issue, but it is the minimum required to create a task. --- cli/exp_task.go | 1 + cli/exp_taskcreate.go | 127 +++++++++++++++++++++ cli/exp_taskcreate_test.go | 227 +++++++++++++++++++++++++++++++++++++ 3 files changed, 355 insertions(+) create mode 100644 cli/exp_taskcreate.go create mode 100644 cli/exp_taskcreate_test.go diff --git a/cli/exp_task.go b/cli/exp_task.go index 81316d155000d..860f7b954f47f 100644 --- a/cli/exp_task.go +++ b/cli/exp_task.go @@ -14,6 +14,7 @@ func (r *RootCmd) tasksCommand() *serpent.Command { }, Children: []*serpent.Command{ r.taskList(), + r.taskCreate(), }, } return cmd diff --git a/cli/exp_taskcreate.go b/cli/exp_taskcreate.go new file mode 100644 index 0000000000000..b23da632a12c2 --- /dev/null +++ b/cli/exp_taskcreate.go @@ -0,0 +1,127 @@ +package cli + +import ( + "fmt" + "strings" + "time" + + "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) taskCreate() *serpent.Command { + var ( + orgContext = NewOrganizationContext() + client = new(codersdk.Client) + + templateName string + templateVersionName string + presetName string + taskInput string + ) + + return &serpent.Command{ + Use: "create [template]", + Short: "Create an experimental task", + Middleware: serpent.Chain( + serpent.RequireRangeArgs(0, 1), + r.InitClient(client), + ), + Options: serpent.OptionSet{ + { + Flag: "input", + Env: "CODER_TASK_INPUT", + Value: serpent.StringOf(&taskInput), + Required: true, + }, + { + Env: "CODER_TASK_TEMPLATE_NAME", + Value: serpent.StringOf(&templateName), + }, + { + Env: "CODER_TASK_TEMPLATE_VERSION", + Value: serpent.StringOf(&templateVersionName), + }, + { + Flag: "preset", + Env: "CODER_TASK_PRESET_NAME", + Value: serpent.StringOf(&presetName), + Default: PresetNone, + }, + }, + Handler: func(inv *serpent.Invocation) error { + var ( + ctx = inv.Context() + expClient = codersdk.NewExperimentalClient(client) + + templateVersionID uuid.UUID + templateVersionPresetID uuid.UUID + ) + + organization, err := orgContext.Selected(inv, client) + if err != nil { + return xerrors.Errorf("get current organization: %w", err) + } + + if len(inv.Args) > 0 { + templateName, templateVersionName, _ = strings.Cut(inv.Args[0], "@") + } + + if templateName == "" { + return xerrors.Errorf("template name not provided") + } + + if templateVersionName != "" { + templateVersion, err := client.TemplateVersionByOrganizationAndName(ctx, organization.ID, templateName, templateVersionName) + if err != nil { + return xerrors.Errorf("get template version: %w", err) + } + + templateVersionID = templateVersion.ID + } else { + template, err := client.TemplateByName(ctx, organization.ID, templateName) + if err != nil { + return xerrors.Errorf("get template: %w", err) + } + + templateVersionID = template.ActiveVersionID + } + + if presetName != PresetNone { + templatePresets, err := client.TemplateVersionPresets(ctx, templateVersionID) + if err != nil { + return xerrors.Errorf("get template presets: %w", err) + } + + preset, err := resolvePreset(templatePresets, presetName) + if err != nil { + return xerrors.Errorf("resolve preset: %w", err) + } + + templateVersionPresetID = preset.ID + } + + workspace, err := expClient.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{ + TemplateVersionID: templateVersionID, + TemplateVersionPresetID: templateVersionPresetID, + Prompt: taskInput, + }) + if err != nil { + return xerrors.Errorf("create task: %w", err) + } + + _, _ = fmt.Fprintf( + inv.Stdout, + "The task %s has been created at %s!\n", + cliui.Keyword(workspace.Name), + cliui.Timestamp(time.Now()), + ) + + return nil + }, + } +} diff --git a/cli/exp_taskcreate_test.go b/cli/exp_taskcreate_test.go new file mode 100644 index 0000000000000..7a4a4bfb5a43e --- /dev/null +++ b/cli/exp_taskcreate_test.go @@ -0,0 +1,227 @@ +package cli_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "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/cli/cliui" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" + "github.com/coder/serpent" +) + +func TestTaskCreate(t *testing.T) { + t.Parallel() + + var ( + organizationID = uuid.New() + templateID = uuid.New() + templateVersionID = uuid.New() + templateVersionPresetID = uuid.New() + ) + + templateAndVersionFoundHandler := func(t *testing.T, ctx context.Context, templateName, templateVersionName, presetName, prompt string) http.HandlerFunc { + t.Helper() + + return func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v2/users/me/organizations": + httpapi.Write(ctx, w, http.StatusOK, []codersdk.Organization{ + {MinimalOrganization: codersdk.MinimalOrganization{ + ID: organizationID, + }}, + }) + case fmt.Sprintf("/api/v2/organizations/%s/templates/my-template/versions/my-template-version", organizationID): + httpapi.Write(ctx, w, http.StatusOK, codersdk.TemplateVersion{ + ID: templateVersionID, + }) + case fmt.Sprintf("/api/v2/organizations/%s/templates/my-template", organizationID): + httpapi.Write(ctx, w, http.StatusOK, codersdk.Template{ + ID: templateID, + ActiveVersionID: templateVersionID, + }) + case fmt.Sprintf("/api/v2/templateversions/%s/presets", templateVersionID): + httpapi.Write(ctx, w, http.StatusOK, []codersdk.Preset{ + { + ID: templateVersionPresetID, + Name: presetName, + }, + }) + case "/api/experimental/tasks/me": + var req codersdk.CreateTaskRequest + if !httpapi.Read(ctx, w, r, &req) { + return + } + + assert.Equal(t, prompt, req.Prompt, "prompt mismatch") + assert.Equal(t, templateVersionID, req.TemplateVersionID, "template version mismatch") + + if presetName == "" { + assert.Equal(t, uuid.Nil, req.TemplateVersionPresetID, "expected no template preset id") + } else { + assert.Equal(t, templateVersionPresetID, req.TemplateVersionPresetID, "template version preset id mismatch") + } + + httpapi.Write(ctx, w, http.StatusCreated, codersdk.Workspace{ + Name: "task-wild-goldfish-27", + }) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + } + } + + tests := []struct { + args []string + env []string + expectError string + expectOutput string + handler func(t *testing.T, ctx context.Context) http.HandlerFunc + }{ + { + args: []string{"my-template@my-template-version", "--input", "my custom prompt"}, + expectOutput: fmt.Sprintf("The task %s has been created", cliui.Keyword("task-wild-goldfish-27")), + handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { + return templateAndVersionFoundHandler(t, ctx, "my-template", "my-template-version", "", "my custom prompt") + }, + }, + { + args: []string{"my-template", "--input", "my custom prompt"}, + env: []string{"CODER_TASK_TEMPLATE_VERSION=my-template-version"}, + expectOutput: fmt.Sprintf("The task %s has been created", cliui.Keyword("task-wild-goldfish-27")), + handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { + return templateAndVersionFoundHandler(t, ctx, "my-template", "my-template-version", "", "my custom prompt") + }, + }, + { + args: []string{"--input", "my custom prompt"}, + env: []string{"CODER_TASK_TEMPLATE_NAME=my-template", "CODER_TASK_TEMPLATE_VERSION=my-template-version"}, + expectOutput: fmt.Sprintf("The task %s has been created", cliui.Keyword("task-wild-goldfish-27")), + handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { + return templateAndVersionFoundHandler(t, ctx, "my-template", "my-template-version", "", "my custom prompt") + }, + }, + { + env: []string{"CODER_TASK_TEMPLATE_NAME=my-template", "CODER_TASK_TEMPLATE_VERSION=my-template-version", "CODER_TASK_INPUT=my custom prompt"}, + expectOutput: fmt.Sprintf("The task %s has been created", cliui.Keyword("task-wild-goldfish-27")), + handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { + return templateAndVersionFoundHandler(t, ctx, "my-template", "my-template-version", "", "my custom prompt") + }, + }, + { + args: []string{"my-template", "--input", "my custom prompt"}, + expectOutput: fmt.Sprintf("The task %s has been created", cliui.Keyword("task-wild-goldfish-27")), + handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { + return templateAndVersionFoundHandler(t, ctx, "my-template", "", "", "my custom prompt") + }, + }, + { + args: []string{"my-template", "--input", "my custom prompt", "--preset", "my-preset"}, + expectOutput: fmt.Sprintf("The task %s has been created", cliui.Keyword("task-wild-goldfish-27")), + handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { + return templateAndVersionFoundHandler(t, ctx, "my-template", "", "my-preset", "my custom prompt") + }, + }, + { + args: []string{"my-template", "--input", "my custom prompt"}, + env: []string{"CODER_TASK_PRESET_NAME=my-preset"}, + expectOutput: fmt.Sprintf("The task %s has been created", cliui.Keyword("task-wild-goldfish-27")), + handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { + return templateAndVersionFoundHandler(t, ctx, "my-template", "", "my-preset", "my custom prompt") + }, + }, + { + args: []string{"my-template", "--input", "my custom prompt", "--preset", "not-real-preset"}, + expectError: `preset "not-real-preset" not found`, + handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { + return templateAndVersionFoundHandler(t, ctx, "my-template", "", "my-preset", "my custom prompt") + }, + }, + { + args: []string{"my-template@not-real-template-version", "--input", "my custom prompt"}, + 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/organizations": + httpapi.Write(ctx, w, http.StatusOK, []codersdk.Organization{ + {MinimalOrganization: codersdk.MinimalOrganization{ + ID: organizationID, + }}, + }) + case fmt.Sprintf("/api/v2/organizations/%s/templates/my-template/versions/not-real-template-version", organizationID): + httpapi.ResourceNotFound(w) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + } + }, + }, + { + args: []string{"not-real-template", "--input", "my custom prompt"}, + 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/organizations": + httpapi.Write(ctx, w, http.StatusOK, []codersdk.Organization{ + {MinimalOrganization: codersdk.MinimalOrganization{ + ID: organizationID, + }}, + }) + case fmt.Sprintf("/api/v2/organizations/%s/templates/not-real-template", organizationID): + httpapi.ResourceNotFound(w) + 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 = new(codersdk.Client) + args = []string{"exp", "task", "create"} + sb strings.Builder + err error + ) + + t.Cleanup(srv.Close) + + client.URL, err = url.Parse(srv.URL) + require.NoError(t, err) + + inv, root := clitest.New(t, append(args, tt.args...)...) + inv.Environ = serpent.ParseEnviron(tt.env, "") + inv.Stdout = &sb + inv.Stderr = &sb + clitest.SetupConfig(t, client, root) + + err = inv.WithContext(ctx).Run() + if tt.expectError == "" { + assert.NoError(t, err) + } else { + assert.ErrorContains(t, err, tt.expectError) + } + + assert.Contains(t, sb.String(), tt.expectOutput) + }) + } +} From ef0d74fb750f6e4c342c9ed12fc1ae630b4ea69b Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 26 Aug 2025 09:26:11 -0500 Subject: [PATCH 2/6] chore: improve performance of 'GetLatestWorkspaceBuildsByWorkspaceIDs' (#19452) Closes https://github.com/coder/internal/issues/716 This prevents a scan over the entire `workspace_build` table by removing a `join`. This is still imperfect as we are still scanning over the number of builds for the workspaces in the arguments. Ideally we would have some index or something precomputed. Then we could skip scanning over the builds for the correct workspaces that are not the latest. --- coderd/database/querier_test.go | 73 +++++++++++++++++++++ coderd/database/queries.sql.go | 23 +++---- coderd/database/queries/workspacebuilds.sql | 24 +++---- 3 files changed, 92 insertions(+), 28 deletions(-) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 18c10d6388f37..a8b3c186edd8b 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -32,6 +32,7 @@ import ( "github.com/coder/coder/v2/coderd/provisionerdserver" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisionersdk" "github.com/coder/coder/v2/testutil" @@ -6579,3 +6580,75 @@ func TestWorkspaceBuildDeadlineConstraint(t *testing.T) { } } } + +// TestGetLatestWorkspaceBuildsByWorkspaceIDs populates the database with +// workspaces and builds. It then tests that +// GetLatestWorkspaceBuildsByWorkspaceIDs returns the latest build for some +// subset of the workspaces. +func TestGetLatestWorkspaceBuildsByWorkspaceIDs(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + + org := dbgen.Organization(t, db, database.Organization{}) + admin := dbgen.User(t, db, database.User{}) + + tv := dbfake.TemplateVersion(t, db). + Seed(database.TemplateVersion{ + OrganizationID: org.ID, + CreatedBy: admin.ID, + }). + Do() + + users := make([]database.User, 5) + wrks := make([][]database.WorkspaceTable, len(users)) + exp := make(map[uuid.UUID]database.WorkspaceBuild) + for i := range users { + users[i] = dbgen.User(t, db, database.User{}) + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: users[i].ID, + OrganizationID: org.ID, + }) + + // Each user gets 2 workspaces. + wrks[i] = make([]database.WorkspaceTable, 2) + for wi := range wrks[i] { + wrks[i][wi] = dbgen.Workspace(t, db, database.WorkspaceTable{ + TemplateID: tv.Template.ID, + OwnerID: users[i].ID, + }) + + // Choose a deterministic number of builds per workspace + // No more than 5 builds though, that would be excessive. + for j := int32(1); int(j) <= (i+wi)%5; j++ { + wb := dbfake.WorkspaceBuild(t, db, wrks[i][wi]). + Seed(database.WorkspaceBuild{ + WorkspaceID: wrks[i][wi].ID, + BuildNumber: j + 1, + }). + Do() + + exp[wrks[i][wi].ID] = wb.Build // Save the final workspace build + } + } + } + + // Only take half the users. And only take 1 workspace per user for the test. + // The others are just noice. This just queries a subset of workspaces and builds + // to make sure the noise doesn't interfere with the results. + assertWrks := wrks[:len(users)/2] + ctx := testutil.Context(t, testutil.WaitLong) + ids := slice.Convert[[]database.WorkspaceTable, uuid.UUID](assertWrks, func(pair []database.WorkspaceTable) uuid.UUID { + return pair[0].ID + }) + + require.Greater(t, len(ids), 0, "expected some workspace ids for test") + builds, err := db.GetLatestWorkspaceBuildsByWorkspaceIDs(ctx, ids) + require.NoError(t, err) + for _, b := range builds { + expB, ok := exp[b.WorkspaceID] + require.Truef(t, ok, "unexpected workspace build for workspace id %s", b.WorkspaceID) + require.Equalf(t, expB.ID, b.ID, "unexpected workspace build id for workspace id %s", b.WorkspaceID) + require.Equal(t, expB.BuildNumber, b.BuildNumber, "unexpected build number") + } +} diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 2f56b422f350b..014c433cab690 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -18983,20 +18983,15 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, w } const getLatestWorkspaceBuildsByWorkspaceIDs = `-- name: GetLatestWorkspaceBuildsByWorkspaceIDs :many -SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost, wb.max_deadline, wb.template_version_preset_id, wb.has_ai_task, wb.ai_task_sidebar_app_id, wb.has_external_agent, wb.initiator_by_avatar_url, wb.initiator_by_username, wb.initiator_by_name -FROM ( - SELECT - workspace_id, MAX(build_number) as max_build_number - FROM - workspace_build_with_user AS workspace_builds - WHERE - workspace_id = ANY($1 :: uuid [ ]) - GROUP BY - workspace_id -) m -JOIN - workspace_build_with_user AS wb -ON m.workspace_id = wb.workspace_id AND m.max_build_number = wb.build_number +SELECT + DISTINCT ON (workspace_id) + id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, has_external_agent, initiator_by_avatar_url, initiator_by_username, initiator_by_name +FROM + workspace_build_with_user AS workspace_builds +WHERE + workspace_id = ANY($1 :: uuid [ ]) +ORDER BY + workspace_id, build_number DESC -- latest first ` func (q *sqlQuerier) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceBuild, error) { diff --git a/coderd/database/queries/workspacebuilds.sql b/coderd/database/queries/workspacebuilds.sql index 6c020f5a97f50..0736c5514b3f7 100644 --- a/coderd/database/queries/workspacebuilds.sql +++ b/coderd/database/queries/workspacebuilds.sql @@ -76,20 +76,16 @@ LIMIT 1; -- name: GetLatestWorkspaceBuildsByWorkspaceIDs :many -SELECT wb.* -FROM ( - SELECT - workspace_id, MAX(build_number) as max_build_number - FROM - workspace_build_with_user AS workspace_builds - WHERE - workspace_id = ANY(@ids :: uuid [ ]) - GROUP BY - workspace_id -) m -JOIN - workspace_build_with_user AS wb -ON m.workspace_id = wb.workspace_id AND m.max_build_number = wb.build_number; +SELECT + DISTINCT ON (workspace_id) + * +FROM + workspace_build_with_user AS workspace_builds +WHERE + workspace_id = ANY(@ids :: uuid [ ]) +ORDER BY + workspace_id, build_number DESC -- latest first +; -- name: InsertWorkspaceBuild :exec INSERT INTO From c19f430f35fce72d8eafc274efc6eeefbc248b29 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 26 Aug 2025 15:57:44 +0100 Subject: [PATCH 3/6] fix(cli): display workspace created at time instead of current time (#19553) Applying a suggestion from https://github.com/coder/coder/pull/19492#discussion_r2301175791 --- cli/exp_taskcreate.go | 3 +-- cli/exp_taskcreate_test.go | 20 ++++++++++++-------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/cli/exp_taskcreate.go b/cli/exp_taskcreate.go index b23da632a12c2..40f45a903c85b 100644 --- a/cli/exp_taskcreate.go +++ b/cli/exp_taskcreate.go @@ -3,7 +3,6 @@ package cli import ( "fmt" "strings" - "time" "github.com/google/uuid" "golang.org/x/xerrors" @@ -118,7 +117,7 @@ func (r *RootCmd) taskCreate() *serpent.Command { inv.Stdout, "The task %s has been created at %s!\n", cliui.Keyword(workspace.Name), - cliui.Timestamp(time.Now()), + cliui.Timestamp(workspace.CreatedAt), ) return nil diff --git a/cli/exp_taskcreate_test.go b/cli/exp_taskcreate_test.go index 7a4a4bfb5a43e..520838c53acca 100644 --- a/cli/exp_taskcreate_test.go +++ b/cli/exp_taskcreate_test.go @@ -8,6 +8,7 @@ import ( "net/url" "strings" "testing" + "time" "github.com/google/uuid" "github.com/stretchr/testify/assert" @@ -25,6 +26,8 @@ func TestTaskCreate(t *testing.T) { t.Parallel() var ( + taskCreatedAt = time.Now() + organizationID = uuid.New() templateID = uuid.New() templateVersionID = uuid.New() @@ -74,7 +77,8 @@ func TestTaskCreate(t *testing.T) { } httpapi.Write(ctx, w, http.StatusCreated, codersdk.Workspace{ - Name: "task-wild-goldfish-27", + Name: "task-wild-goldfish-27", + CreatedAt: taskCreatedAt, }) default: t.Errorf("unexpected path: %s", r.URL.Path) @@ -91,7 +95,7 @@ func TestTaskCreate(t *testing.T) { }{ { args: []string{"my-template@my-template-version", "--input", "my custom prompt"}, - expectOutput: fmt.Sprintf("The task %s has been created", cliui.Keyword("task-wild-goldfish-27")), + expectOutput: fmt.Sprintf("The task %s has been created at %s!", cliui.Keyword("task-wild-goldfish-27"), cliui.Timestamp(taskCreatedAt)), handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { return templateAndVersionFoundHandler(t, ctx, "my-template", "my-template-version", "", "my custom prompt") }, @@ -99,7 +103,7 @@ func TestTaskCreate(t *testing.T) { { args: []string{"my-template", "--input", "my custom prompt"}, env: []string{"CODER_TASK_TEMPLATE_VERSION=my-template-version"}, - expectOutput: fmt.Sprintf("The task %s has been created", cliui.Keyword("task-wild-goldfish-27")), + expectOutput: fmt.Sprintf("The task %s has been created at %s!", cliui.Keyword("task-wild-goldfish-27"), cliui.Timestamp(taskCreatedAt)), handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { return templateAndVersionFoundHandler(t, ctx, "my-template", "my-template-version", "", "my custom prompt") }, @@ -107,28 +111,28 @@ func TestTaskCreate(t *testing.T) { { args: []string{"--input", "my custom prompt"}, env: []string{"CODER_TASK_TEMPLATE_NAME=my-template", "CODER_TASK_TEMPLATE_VERSION=my-template-version"}, - expectOutput: fmt.Sprintf("The task %s has been created", cliui.Keyword("task-wild-goldfish-27")), + expectOutput: fmt.Sprintf("The task %s has been created at %s!", cliui.Keyword("task-wild-goldfish-27"), cliui.Timestamp(taskCreatedAt)), handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { return templateAndVersionFoundHandler(t, ctx, "my-template", "my-template-version", "", "my custom prompt") }, }, { env: []string{"CODER_TASK_TEMPLATE_NAME=my-template", "CODER_TASK_TEMPLATE_VERSION=my-template-version", "CODER_TASK_INPUT=my custom prompt"}, - expectOutput: fmt.Sprintf("The task %s has been created", cliui.Keyword("task-wild-goldfish-27")), + expectOutput: fmt.Sprintf("The task %s has been created at %s!", cliui.Keyword("task-wild-goldfish-27"), cliui.Timestamp(taskCreatedAt)), handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { return templateAndVersionFoundHandler(t, ctx, "my-template", "my-template-version", "", "my custom prompt") }, }, { args: []string{"my-template", "--input", "my custom prompt"}, - expectOutput: fmt.Sprintf("The task %s has been created", cliui.Keyword("task-wild-goldfish-27")), + expectOutput: fmt.Sprintf("The task %s has been created at %s!", cliui.Keyword("task-wild-goldfish-27"), cliui.Timestamp(taskCreatedAt)), handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { return templateAndVersionFoundHandler(t, ctx, "my-template", "", "", "my custom prompt") }, }, { args: []string{"my-template", "--input", "my custom prompt", "--preset", "my-preset"}, - expectOutput: fmt.Sprintf("The task %s has been created", cliui.Keyword("task-wild-goldfish-27")), + expectOutput: fmt.Sprintf("The task %s has been created at %s!", cliui.Keyword("task-wild-goldfish-27"), cliui.Timestamp(taskCreatedAt)), handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { return templateAndVersionFoundHandler(t, ctx, "my-template", "", "my-preset", "my custom prompt") }, @@ -136,7 +140,7 @@ func TestTaskCreate(t *testing.T) { { args: []string{"my-template", "--input", "my custom prompt"}, env: []string{"CODER_TASK_PRESET_NAME=my-preset"}, - expectOutput: fmt.Sprintf("The task %s has been created", cliui.Keyword("task-wild-goldfish-27")), + expectOutput: fmt.Sprintf("The task %s has been created at %s!", cliui.Keyword("task-wild-goldfish-27"), cliui.Timestamp(taskCreatedAt)), handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { return templateAndVersionFoundHandler(t, ctx, "my-template", "", "my-preset", "my custom prompt") }, From 5baaf2747d10e96d10c5ec04716f9e31822b36bc Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 26 Aug 2025 16:01:35 +0100 Subject: [PATCH 4/6] feat(cli): implement exp task status command (#19533) Closes https://github.com/coder/internal/issues/900 - Implements `coder exp task status` - Adds `testutil.MustURL` helper --- cli/exp_task.go | 1 + cli/exp_task_status.go | 171 +++++++++++++++++++++++ cli/exp_task_status_test.go | 270 ++++++++++++++++++++++++++++++++++++ testutil/url.go | 14 ++ 4 files changed, 456 insertions(+) create mode 100644 cli/exp_task_status.go create mode 100644 cli/exp_task_status_test.go create mode 100644 testutil/url.go diff --git a/cli/exp_task.go b/cli/exp_task.go index 860f7b954f47f..005138050b2eb 100644 --- a/cli/exp_task.go +++ b/cli/exp_task.go @@ -15,6 +15,7 @@ func (r *RootCmd) tasksCommand() *serpent.Command { Children: []*serpent.Command{ r.taskList(), r.taskCreate(), + r.taskStatus(), }, } return cmd diff --git a/cli/exp_task_status.go b/cli/exp_task_status.go new file mode 100644 index 0000000000000..7b4b75c1a8ef9 --- /dev/null +++ b/cli/exp_task_status.go @@ -0,0 +1,171 @@ +package cli + +import ( + "fmt" + "strings" + "time" + + "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) taskStatus() *serpent.Command { + var ( + client = new(codersdk.Client) + formatter = cliui.NewOutputFormatter( + cliui.TableFormat( + []taskStatusRow{}, + []string{ + "state changed", + "status", + "state", + "message", + }, + ), + cliui.ChangeFormatterData( + cliui.JSONFormat(), + func(data any) (any, error) { + rows, ok := data.([]taskStatusRow) + if !ok { + return nil, xerrors.Errorf("expected []taskStatusRow, got %T", data) + } + if len(rows) != 1 { + return nil, xerrors.Errorf("expected exactly 1 row, got %d", len(rows)) + } + return rows[0], nil + }, + ), + ) + watchArg bool + watchIntervalArg time.Duration + ) + cmd := &serpent.Command{ + Short: "Show the status of a task.", + Use: "status", + Aliases: []string{"stat"}, + Options: serpent.OptionSet{ + { + Default: "false", + Description: "Watch the task status output. This will stream updates to the terminal until the underlying workspace is stopped.", + Flag: "watch", + Name: "watch", + Value: serpent.BoolOf(&watchArg), + }, + { + Default: "1s", + Description: "Interval to poll the task for updates. Only used in tests.", + Hidden: true, + Flag: "watch-interval", + Name: "watch-interval", + Value: serpent.DurationOf(&watchIntervalArg), + }, + }, + Middleware: serpent.Chain( + serpent.RequireNArgs(1), + r.InitClient(client), + ), + Handler: func(i *serpent.Invocation) error { + ctx := i.Context() + ec := codersdk.NewExperimentalClient(client) + identifier := i.Args[0] + + taskID, err := uuid.Parse(identifier) + if err != nil { + // Try to resolve the task as a named workspace + // TODO: right now tasks are still "workspaces" under the hood. + // We should update this once we have a proper task model. + ws, err := namedWorkspace(ctx, client, identifier) + if err != nil { + return err + } + taskID = ws.ID + } + task, err := ec.TaskByID(ctx, taskID) + if err != nil { + return err + } + + out, err := formatter.Format(ctx, toStatusRow(task)) + if err != nil { + return xerrors.Errorf("format task status: %w", err) + } + _, _ = fmt.Fprintln(i.Stdout, out) + + if !watchArg { + return nil + } + + lastStatus := task.Status + lastState := task.CurrentState + t := time.NewTicker(watchIntervalArg) + defer t.Stop() + // TODO: implement streaming updates instead of polling + for range t.C { + task, err := ec.TaskByID(ctx, taskID) + if err != nil { + return err + } + if lastStatus == task.Status && taskStatusEqual(lastState, task.CurrentState) { + continue + } + out, err := formatter.Format(ctx, toStatusRow(task)) + if err != nil { + return xerrors.Errorf("format task status: %w", err) + } + // hack: skip the extra column header from formatter + if formatter.FormatID() != cliui.JSONFormat().ID() { + out = strings.SplitN(out, "\n", 2)[1] + } + _, _ = fmt.Fprintln(i.Stdout, out) + + if task.Status == codersdk.WorkspaceStatusStopped { + return nil + } + lastStatus = task.Status + lastState = task.CurrentState + } + return nil + }, + } + formatter.AttachOptions(&cmd.Options) + return cmd +} + +func taskStatusEqual(s1, s2 *codersdk.TaskStateEntry) bool { + if s1 == nil && s2 == nil { + return true + } + if s1 == nil || s2 == nil { + return false + } + return s1.State == s2.State +} + +type taskStatusRow struct { + codersdk.Task `table:"-"` + ChangedAgo string `json:"-" table:"state changed,default_sort"` + Timestamp time.Time `json:"-" table:"-"` + TaskStatus string `json:"-" table:"status"` + TaskState string `json:"-" table:"state"` + Message string `json:"-" table:"message"` +} + +func toStatusRow(task codersdk.Task) []taskStatusRow { + tsr := taskStatusRow{ + Task: task, + ChangedAgo: time.Since(task.UpdatedAt).Truncate(time.Second).String() + " ago", + Timestamp: task.UpdatedAt, + TaskStatus: string(task.Status), + } + if task.CurrentState != nil { + tsr.ChangedAgo = time.Since(task.CurrentState.Timestamp).Truncate(time.Second).String() + " ago" + tsr.Timestamp = task.CurrentState.Timestamp + tsr.TaskState = string(task.CurrentState.State) + tsr.Message = task.CurrentState.Message + } + return []taskStatusRow{tsr} +} diff --git a/cli/exp_task_status_test.go b/cli/exp_task_status_test.go new file mode 100644 index 0000000000000..6aa52ff3883d2 --- /dev/null +++ b/cli/exp_task_status_test.go @@ -0,0 +1,270 @@ +package cli_test + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "golang.org/x/xerrors" + + "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_TaskStatus(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + args []string + expectOutput string + expectError string + hf func(context.Context, time.Time) func(http.ResponseWriter, *http.Request) + }{ + { + args: []string{"doesnotexist"}, + expectError: httpapi.ResourceNotFoundResponse.Message, + hf: func(ctx context.Context, _ time.Time) func(w http.ResponseWriter, r *http.Request) { + 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{"err-fetching-workspace"}, + expectError: assert.AnError.Error(), + hf: func(ctx context.Context, _ time.Time) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v2/users/me/workspace/err-fetching-workspace": + httpapi.Write(ctx, w, http.StatusOK, codersdk.Workspace{ + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + }) + case "/api/experimental/tasks/me/11111111-1111-1111-1111-111111111111": + httpapi.InternalServerError(w, assert.AnError) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + } + }, + }, + { + args: []string{"exists"}, + expectOutput: `STATE CHANGED STATUS STATE MESSAGE +0s ago running working Thinking furiously...`, + hf: func(ctx context.Context, now time.Time) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v2/users/me/workspace/exists": + httpapi.Write(ctx, w, http.StatusOK, codersdk.Workspace{ + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + }) + case "/api/experimental/tasks/me/11111111-1111-1111-1111-111111111111": + httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{ + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + Status: codersdk.WorkspaceStatusRunning, + CreatedAt: now, + UpdatedAt: now, + CurrentState: &codersdk.TaskStateEntry{ + State: codersdk.TaskStateWorking, + Timestamp: now, + Message: "Thinking furiously...", + }, + }) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + } + }, + }, + { + args: []string{"exists", "--watch"}, + expectOutput: ` +STATE CHANGED STATUS STATE MESSAGE +4s ago running +3s ago running working Reticulating splines... +2s ago running completed Splines reticulated successfully! +2s ago stopping completed Splines reticulated successfully! +2s ago stopped completed Splines reticulated successfully!`, + hf: func(ctx context.Context, now time.Time) func(http.ResponseWriter, *http.Request) { + var calls atomic.Int64 + return func(w http.ResponseWriter, r *http.Request) { + defer calls.Add(1) + switch r.URL.Path { + case "/api/v2/users/me/workspace/exists": + httpapi.Write(ctx, w, http.StatusOK, codersdk.Workspace{ + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + }) + case "/api/experimental/tasks/me/11111111-1111-1111-1111-111111111111": + switch calls.Load() { + case 0: + httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{ + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + Status: codersdk.WorkspaceStatusPending, + CreatedAt: now.Add(-5 * time.Second), + UpdatedAt: now.Add(-5 * time.Second), + }) + case 1: + httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{ + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + Status: codersdk.WorkspaceStatusRunning, + CreatedAt: now.Add(-5 * time.Second), + UpdatedAt: now.Add(-4 * time.Second), + }) + case 2: + httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{ + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + Status: codersdk.WorkspaceStatusRunning, + CreatedAt: now.Add(-5 * time.Second), + UpdatedAt: now.Add(-4 * time.Second), + CurrentState: &codersdk.TaskStateEntry{ + State: codersdk.TaskStateWorking, + Timestamp: now.Add(-3 * time.Second), + Message: "Reticulating splines...", + }, + }) + case 3: + httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{ + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + Status: codersdk.WorkspaceStatusRunning, + CreatedAt: now.Add(-5 * time.Second), + UpdatedAt: now.Add(-4 * time.Second), + CurrentState: &codersdk.TaskStateEntry{ + State: codersdk.TaskStateCompleted, + Timestamp: now.Add(-2 * time.Second), + Message: "Splines reticulated successfully!", + }, + }) + case 4: + httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{ + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + Status: codersdk.WorkspaceStatusStopping, + CreatedAt: now.Add(-5 * time.Second), + UpdatedAt: now.Add(-1 * time.Second), + CurrentState: &codersdk.TaskStateEntry{ + State: codersdk.TaskStateCompleted, + Timestamp: now.Add(-2 * time.Second), + Message: "Splines reticulated successfully!", + }, + }) + case 5: + httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{ + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + Status: codersdk.WorkspaceStatusStopped, + CreatedAt: now.Add(-5 * time.Second), + UpdatedAt: now, + CurrentState: &codersdk.TaskStateEntry{ + State: codersdk.TaskStateCompleted, + Timestamp: now.Add(-2 * time.Second), + Message: "Splines reticulated successfully!", + }, + }) + default: + httpapi.InternalServerError(w, xerrors.New("too many calls!")) + return + } + default: + httpapi.InternalServerError(w, xerrors.Errorf("unexpected path: %q", r.URL.Path)) + } + } + }, + }, + { + args: []string{"exists", "--output", "json"}, + expectOutput: `{ + "id": "11111111-1111-1111-1111-111111111111", + "organization_id": "00000000-0000-0000-0000-000000000000", + "owner_id": "00000000-0000-0000-0000-000000000000", + "name": "", + "template_id": "00000000-0000-0000-0000-000000000000", + "workspace_id": null, + "initial_prompt": "", + "status": "running", + "current_state": { + "timestamp": "2025-08-26T12:34:57Z", + "state": "working", + "message": "Thinking furiously...", + "uri": "" + }, + "created_at": "2025-08-26T12:34:56Z", + "updated_at": "2025-08-26T12:34:56Z" +}`, + hf: func(ctx context.Context, _ time.Time) func(w http.ResponseWriter, r *http.Request) { + ts := time.Date(2025, 8, 26, 12, 34, 56, 0, time.UTC) + return func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v2/users/me/workspace/exists": + httpapi.Write(ctx, w, http.StatusOK, codersdk.Workspace{ + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + }) + case "/api/experimental/tasks/me/11111111-1111-1111-1111-111111111111": + httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{ + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + Status: codersdk.WorkspaceStatusRunning, + CreatedAt: ts, + UpdatedAt: ts, + CurrentState: &codersdk.TaskStateEntry{ + State: codersdk.TaskStateWorking, + Timestamp: ts.Add(time.Second), + Message: "Thinking furiously...", + }, + }) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + } + }, + }, + } { + t.Run(strings.Join(tc.args, ","), func(t *testing.T) { + t.Parallel() + + var ( + ctx = testutil.Context(t, testutil.WaitShort) + now = time.Now().UTC() // TODO: replace with quartz + srv = httptest.NewServer(http.HandlerFunc(tc.hf(ctx, now))) + client = new(codersdk.Client) + sb = strings.Builder{} + args = []string{"exp", "task", "status", "--watch-interval", testutil.IntervalFast.String()} + ) + + t.Cleanup(srv.Close) + client.URL = testutil.MustURL(t, srv.URL) + args = append(args, tc.args...) + inv, root := clitest.New(t, args...) + inv.Stdout = &sb + inv.Stderr = &sb + clitest.SetupConfig(t, client, root) + err := inv.WithContext(ctx).Run() + if tc.expectError == "" { + assert.NoError(t, err) + } else { + assert.ErrorContains(t, err, tc.expectError) + } + if diff := tableDiff(tc.expectOutput, sb.String()); diff != "" { + t.Errorf("unexpected output diff (-want +got):\n%s", diff) + } + }) + } +} + +func tableDiff(want, got string) string { + var gotTrimmed strings.Builder + for _, line := range strings.Split(got, "\n") { + _, _ = gotTrimmed.WriteString(strings.TrimRight(line, " ") + "\n") + } + return cmp.Diff(strings.TrimSpace(want), strings.TrimSpace(gotTrimmed.String())) +} diff --git a/testutil/url.go b/testutil/url.go new file mode 100644 index 0000000000000..1b6e1caa4f3a0 --- /dev/null +++ b/testutil/url.go @@ -0,0 +1,14 @@ +package testutil + +import ( + "net/url" + "testing" +) + +func MustURL(t testing.TB, raw string) *url.URL { + u, err := url.Parse(raw) + if err != nil { + t.Fatal(err) + } + return u +} From a1546b54144151bca013eef122e3787e2014f83a Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Tue, 26 Aug 2025 12:24:52 -0300 Subject: [PATCH 5/6] refactor: replace task prompt by workspace name in the topbar (#19531) Fixes https://github.com/coder/coder/issues/19524 **Screenshot:** Screenshot 2025-08-25 at 14 59 11 **Demo:** https://github.com/user-attachments/assets/040490ea-b276-48d7-9f3a-d8261d982bb5 **Changes:** - Change "View workspace" button to icon + "Workspace" - Updated the title to use the workspace name instead of the prompt - Added a prompt button, so the user can see what is the prompt that is running + copy it easily --- site/src/pages/TaskPage/TaskPage.tsx | 6 +- site/src/pages/TaskPage/TaskTopbar.tsx | 77 ++++++++++++++++++++++---- 2 files changed, 68 insertions(+), 15 deletions(-) diff --git a/site/src/pages/TaskPage/TaskPage.tsx b/site/src/pages/TaskPage/TaskPage.tsx index 4a65c6f1be993..57f6c81cff277 100644 --- a/site/src/pages/TaskPage/TaskPage.tsx +++ b/site/src/pages/TaskPage/TaskPage.tsx @@ -151,7 +151,7 @@ const TaskPage = () => { return ( <> - {pageTitle(ellipsizeText(task.prompt, 64))} + {pageTitle(task.workspace.name)}
@@ -265,7 +265,3 @@ export const data = { } satisfies Task; }, }; - -const ellipsizeText = (text: string, maxLength = 80): string => { - return text.length <= maxLength ? text : `${text.slice(0, maxLength - 3)}...`; -}; diff --git a/site/src/pages/TaskPage/TaskTopbar.tsx b/site/src/pages/TaskPage/TaskTopbar.tsx index e7bc9283a16eb..4f51812b4712d 100644 --- a/site/src/pages/TaskPage/TaskTopbar.tsx +++ b/site/src/pages/TaskPage/TaskTopbar.tsx @@ -5,7 +5,14 @@ import { TooltipProvider, TooltipTrigger, } from "components/Tooltip/Tooltip"; -import { ArrowLeftIcon } from "lucide-react"; +import { useClipboard } from "hooks"; +import { + ArrowLeftIcon, + CheckIcon, + CopyIcon, + LaptopMinimalIcon, + TerminalIcon, +} from "lucide-react"; import type { Task } from "modules/tasks/tasks"; import type { FC } from "react"; import { Link as RouterLink } from "react-router"; @@ -15,7 +22,7 @@ type TaskTopbarProps = { task: Task }; export const TaskTopbar: FC = ({ task }) => { return ( -
+
@@ -30,7 +37,9 @@ export const TaskTopbar: FC = ({ task }) => { -

{task.prompt}

+

+ {task.workspace.name} +

{task.workspace.latest_app_status?.uri && (
@@ -38,13 +47,61 @@ export const TaskTopbar: FC = ({ task }) => {
)} - +
+ + + + + + +

+ {task.prompt} +

+ +
+
+
+ + +
); }; + +type CopyPromptButtonProps = { prompt: string }; + +const CopyPromptButton: FC = ({ prompt }) => { + const { copyToClipboard, showCopiedSuccess } = useClipboard({ + textToCopy: prompt, + }); + + return ( + + ); +}; From 59525f879b3a4c29cbfb7cc2ce739f28d2e5aabe Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Tue, 26 Aug 2025 12:45:07 -0300 Subject: [PATCH 6/6] feat: display startup script logs while agent is starting (#19530) Closes https://github.com/coder/coder/issues/19363 **Screenshot:** Screenshot 2025-08-25 at 11 02 25 **Demo:** https://github.com/user-attachments/assets/07a68e30-b776-44f9-b4ca-e2dd8d124281 --- site/src/pages/TaskPage/TaskPage.stories.tsx | 77 +++++++++--- site/src/pages/TaskPage/TaskPage.tsx | 126 ++++++++++++++----- site/src/pages/TaskPage/TaskTopbar.tsx | 2 +- 3 files changed, 156 insertions(+), 49 deletions(-) diff --git a/site/src/pages/TaskPage/TaskPage.stories.tsx b/site/src/pages/TaskPage/TaskPage.stories.tsx index 6a486442ace8c..e44fece019f7b 100644 --- a/site/src/pages/TaskPage/TaskPage.stories.tsx +++ b/site/src/pages/TaskPage/TaskPage.stories.tsx @@ -2,17 +2,17 @@ import { MockFailedWorkspace, MockStartingWorkspace, MockStoppedWorkspace, - MockTemplate, MockWorkspace, - MockWorkspaceAgent, + MockWorkspaceAgentLogSource, + MockWorkspaceAgentReady, + MockWorkspaceAgentStarting, MockWorkspaceApp, MockWorkspaceAppStatus, MockWorkspaceResource, mockApiError, } from "testHelpers/entities"; -import { withProxyProvider } from "testHelpers/storybook"; +import { withProxyProvider, withWebSocket } from "testHelpers/storybook"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { API } from "api/api"; import type { Workspace, WorkspaceApp, @@ -61,56 +61,93 @@ export const WaitingOnBuild: Story = { }, }; -export const WaitingOnBuildWithTemplate: Story = { +export const FailedBuild: Story = { beforeEach: () => { - spyOn(API, "getTemplate").mockResolvedValue(MockTemplate); spyOn(data, "fetchTask").mockResolvedValue({ prompt: "Create competitors page", - workspace: MockStartingWorkspace, + workspace: MockFailedWorkspace, }); }, }; -export const WaitingOnStatus: Story = { +export const TerminatedBuild: Story = { beforeEach: () => { spyOn(data, "fetchTask").mockResolvedValue({ prompt: "Create competitors page", - workspace: { - ...MockWorkspace, - latest_app_status: null, - }, + workspace: MockStoppedWorkspace, }); }, }; -export const FailedBuild: Story = { +export const TerminatedBuildWithStatus: Story = { beforeEach: () => { spyOn(data, "fetchTask").mockResolvedValue({ prompt: "Create competitors page", - workspace: MockFailedWorkspace, + workspace: { + ...MockStoppedWorkspace, + latest_app_status: MockWorkspaceAppStatus, + }, }); }, }; -export const TerminatedBuild: Story = { +export const WaitingOnStatus: Story = { beforeEach: () => { spyOn(data, "fetchTask").mockResolvedValue({ prompt: "Create competitors page", - workspace: MockStoppedWorkspace, + workspace: { + ...MockWorkspace, + latest_app_status: null, + latest_build: { + ...MockWorkspace.latest_build, + resources: [ + { ...MockWorkspaceResource, agents: [MockWorkspaceAgentReady] }, + ], + }, + }, }); }, }; -export const TerminatedBuildWithStatus: Story = { +export const WaitingStartupScripts: Story = { beforeEach: () => { spyOn(data, "fetchTask").mockResolvedValue({ prompt: "Create competitors page", workspace: { - ...MockStoppedWorkspace, - latest_app_status: MockWorkspaceAppStatus, + ...MockWorkspace, + latest_build: { + ...MockWorkspace.latest_build, + has_ai_task: true, + resources: [ + { ...MockWorkspaceResource, agents: [MockWorkspaceAgentStarting] }, + ], + }, }, }); }, + decorators: [withWebSocket], + parameters: { + webSocket: [ + { + event: "message", + data: JSON.stringify( + [ + "\x1b[91mCloning Git repository...", + "\x1b[2;37;41mStarting Docker Daemon...", + "\x1b[1;95mAdding some 🧙magic🧙...", + "Starting VS Code...", + "\r 0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0\r100 1475 0 1475 0 0 4231 0 --:--:-- --:--:-- --:--:-- 4238", + ].map((line, index) => ({ + id: index, + level: "info", + output: line, + source_id: MockWorkspaceAgentLogSource.id, + created_at: new Date("2024-01-01T12:00:00Z").toISOString(), + })), + ), + }, + ], + }, }; export const SidebarAppHealthDisabled: Story = { @@ -223,7 +260,7 @@ const mockResources = ( ...MockWorkspaceResource, agents: [ { - ...MockWorkspaceAgent, + ...MockWorkspaceAgentReady, apps: [ ...(props?.apps ?? []), { diff --git a/site/src/pages/TaskPage/TaskPage.tsx b/site/src/pages/TaskPage/TaskPage.tsx index 57f6c81cff277..4d84d47fb5ff7 100644 --- a/site/src/pages/TaskPage/TaskPage.tsx +++ b/site/src/pages/TaskPage/TaskPage.tsx @@ -1,7 +1,11 @@ import { API } from "api/api"; import { getErrorDetail, getErrorMessage } from "api/errors"; import { template as templateQueryOptions } from "api/queries/templates"; -import type { Workspace, WorkspaceStatus } from "api/typesGenerated"; +import type { + Workspace, + WorkspaceAgent, + WorkspaceStatus, +} from "api/typesGenerated"; import isChromatic from "chromatic/isChromatic"; import { Button } from "components/Button/Button"; import { Loader } from "components/Loader/Loader"; @@ -9,13 +13,16 @@ import { Margins } from "components/Margins/Margins"; import { ScrollArea } from "components/ScrollArea/ScrollArea"; import { useWorkspaceBuildLogs } from "hooks/useWorkspaceBuildLogs"; import { ArrowLeftIcon, RotateCcwIcon } from "lucide-react"; +import { AgentLogs } from "modules/resources/AgentLogs/AgentLogs"; +import { useAgentLogs } from "modules/resources/useAgentLogs"; import { AI_PROMPT_PARAMETER_NAME, type Task } from "modules/tasks/tasks"; import { WorkspaceBuildLogs } from "modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs"; -import { type FC, type ReactNode, useEffect, useRef } from "react"; +import { type FC, type ReactNode, useLayoutEffect, useRef } from "react"; import { Helmet } from "react-helmet-async"; import { useQuery } from "react-query"; import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; import { Link as RouterLink, useParams } from "react-router"; +import type { FixedSizeList } from "react-window"; import { pageTitle } from "utils/page"; import { getActiveTransitionStats, @@ -87,6 +94,7 @@ const TaskPage = () => { } let content: ReactNode = null; + const agent = selectAgent(task); if (waitingStatuses.includes(task.workspace.latest_build.status)) { content = ; @@ -132,6 +140,8 @@ const TaskPage = () => {
); + } else if (agent && ["created", "starting"].includes(agent.lifecycle_state)) { + content = ; } else { content = ( @@ -182,7 +192,7 @@ const TaskBuildingWorkspace: FC = ({ task }) => { const scrollAreaRef = useRef(null); // biome-ignore lint/correctness/useExhaustiveDependencies: this effect should run when build logs change - useEffect(() => { + useLayoutEffect(() => { if (isChromatic()) { return; } @@ -196,34 +206,86 @@ const TaskBuildingWorkspace: FC = ({ task }) => { }, [buildLogs]); return ( -
-
-
-

- Starting your workspace -

-
- Your task will be running in a few moments +
+
+
+
+

+ Starting your workspace +

+

+ Your task will be running in a few moments +

+
+ +
+ + + + +
-
+
+ +
+ ); +}; + +type TaskStartingAgentProps = { + agent: WorkspaceAgent; +}; -
- +const TaskStartingAgent: FC = ({ agent }) => { + const logs = useAgentLogs(agent, true); + const listRef = useRef(null); - - - + useLayoutEffect(() => { + if (listRef.current) { + listRef.current.scrollToItem(logs.length - 1, "end"); + } + }, [logs]); + + return ( +
+
+
+
+

+ Running startup scripts +

+

+ Your task will be running in a few moments +

+
+ +
+
+ ({ + id: l.id, + level: l.level, + output: l.output, + sourceId: l.source_id, + time: l.created_at, + }))} + sources={agent.log_sources} + height={96 * 4} + width="100%" + ref={listRef} + /> +
+
@@ -265,3 +327,11 @@ export const data = { } satisfies Task; }, }; + +function selectAgent(task: Task) { + const agents = task.workspace.latest_build.resources + .flatMap((r) => r.agents) + .filter((a) => !!a); + + return agents.at(0); +} diff --git a/site/src/pages/TaskPage/TaskTopbar.tsx b/site/src/pages/TaskPage/TaskTopbar.tsx index 4f51812b4712d..945a9fc179537 100644 --- a/site/src/pages/TaskPage/TaskTopbar.tsx +++ b/site/src/pages/TaskPage/TaskTopbar.tsx @@ -22,7 +22,7 @@ type TaskTopbarProps = { task: Task }; export const TaskTopbar: FC = ({ task }) => { return ( -
+