From d79a7797c23a65138b4917f80c927be1a87fc854 Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Tue, 19 Aug 2025 13:08:01 +0100 Subject: [PATCH 1/6] fix: exclude prebuilt workspaces from template-level lifecycle updates (#19265) ## Description This PR ensures that lifecycle-related changes made via template schedule updates do **not affect prebuilt workspaces**. Since prebuilds are managed by the reconciliation loop and do not participate in the regular lifecycle executor flow, they must be excluded from any updates triggered by template configuration changes. This includes changes to TTL, dormant-deletion scheduling, deadline and autostart scheduling. ## Changes - Updated SQL query `UpdateWorkspacesTTLByTemplateID` to exclude prebuilt workspaces - Updated SQL query `UpdateWorkspacesDormantDeletingAtByTemplateID` to exclude prebuilt workspaces - Updated application-layer logic to skip any updates to lifecycle parameters if a workspace is a prebuild - Preserved all existing update behavior for regular user workspaces This change guarantees that only lifecycle-managed workspaces are affected when template-level configurations are modified, preserving strict boundaries between prebuild and user workspace lifecycles. Related with: * Issue: https://github.com/coder/coder/issues/18898 * PR: https://github.com/coder/coder/pull/19252 --- coderd/database/queries.sql.go | 13 +- coderd/database/queries/workspaces.sql | 15 +- enterprise/coderd/schedule/template.go | 11 +- enterprise/coderd/schedule/template_test.go | 251 ++++++++++++++++++++ 4 files changed, 282 insertions(+), 8 deletions(-) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 8fc4a94a8ad07..1b63e7c1e960f 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -21552,14 +21552,17 @@ UPDATE workspaces SET deleting_at = CASE WHEN $1::bigint = 0 THEN NULL - WHEN $2::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN ($2::timestamptz) + interval '1 milliseconds' * $1::bigint + WHEN $2::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN ($2::timestamptz) + interval '1 milliseconds' * $1::bigint ELSE dormant_at + interval '1 milliseconds' * $1::bigint END, dormant_at = CASE WHEN $2::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN $2::timestamptz ELSE dormant_at END WHERE template_id = $3 -AND - dormant_at IS NOT NULL + AND dormant_at IS NOT NULL + -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) + -- should not have their dormant or deleting at set, as these are handled by the + -- prebuilds reconciliation loop. + AND workspaces.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite, next_start_at, group_acl, user_acl ` @@ -21618,6 +21621,10 @@ SET ttl = $2 WHERE template_id = $1 + -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) + -- should not have their TTL updated, as they are handled by the prebuilds + -- reconciliation loop. + AND workspaces.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID ` type UpdateWorkspacesTTLByTemplateIDParams struct { diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 34461b50401f4..a3deda6863e85 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -579,7 +579,11 @@ UPDATE SET ttl = $2 WHERE - template_id = $1; + template_id = $1 + -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) + -- should not have their TTL updated, as they are handled by the prebuilds + -- reconciliation loop. + AND workspaces.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID; -- name: UpdateWorkspaceLastUsedAt :exec UPDATE @@ -824,14 +828,17 @@ UPDATE workspaces SET deleting_at = CASE WHEN @time_til_dormant_autodelete_ms::bigint = 0 THEN NULL - WHEN @dormant_at::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN (@dormant_at::timestamptz) + interval '1 milliseconds' * @time_til_dormant_autodelete_ms::bigint + WHEN @dormant_at::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN (@dormant_at::timestamptz) + interval '1 milliseconds' * @time_til_dormant_autodelete_ms::bigint ELSE dormant_at + interval '1 milliseconds' * @time_til_dormant_autodelete_ms::bigint END, dormant_at = CASE WHEN @dormant_at::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN @dormant_at::timestamptz ELSE dormant_at END WHERE template_id = @template_id -AND - dormant_at IS NOT NULL + AND dormant_at IS NOT NULL + -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) + -- should not have their dormant or deleting at set, as these are handled by the + -- prebuilds reconciliation loop. + AND workspaces.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID RETURNING *; -- name: UpdateTemplateWorkspacesLastUsedAt :exec diff --git a/enterprise/coderd/schedule/template.go b/enterprise/coderd/schedule/template.go index 203de46db4168..ed21b8160e2c3 100644 --- a/enterprise/coderd/schedule/template.go +++ b/enterprise/coderd/schedule/template.go @@ -242,6 +242,10 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S nextStartAts := []time.Time{} for _, workspace := range workspaces { + // Skip prebuilt workspaces + if workspace.IsPrebuild() { + continue + } nextStartAt := time.Time{} if workspace.AutostartSchedule.Valid { next, err := agpl.NextAllowedAutostart(s.now(), workspace.AutostartSchedule.String, templateSchedule) @@ -254,7 +258,7 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S nextStartAts = append(nextStartAts, nextStartAt) } - //nolint:gocritic // We need to be able to update information about all workspaces. + //nolint:gocritic // We need to be able to update information about regular user workspaces. if err := db.BatchUpdateWorkspaceNextStartAt(dbauthz.AsSystemRestricted(ctx), database.BatchUpdateWorkspaceNextStartAtParams{ IDs: workspaceIDs, NextStartAts: nextStartAts, @@ -334,6 +338,11 @@ func (s *EnterpriseTemplateScheduleStore) updateWorkspaceBuild(ctx context.Conte return xerrors.Errorf("get workspace %q: %w", build.WorkspaceID, err) } + // Skip lifecycle updates for prebuilt workspaces + if workspace.IsPrebuild() { + return nil + } + job, err := db.GetProvisionerJobByID(ctx, build.JobID) if err != nil { return xerrors.Errorf("get provisioner job %q: %w", build.JobID, err) diff --git a/enterprise/coderd/schedule/template_test.go b/enterprise/coderd/schedule/template_test.go index 2eb13b4eb3554..70dc3084899ad 100644 --- a/enterprise/coderd/schedule/template_test.go +++ b/enterprise/coderd/schedule/template_test.go @@ -1,6 +1,7 @@ package schedule_test import ( + "context" "database/sql" "encoding/json" "fmt" @@ -17,14 +18,18 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbfake" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/notifications/notificationstest" agplschedule "github.com/coder/coder/v2/coderd/schedule" + "github.com/coder/coder/v2/coderd/schedule/cron" + "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/cryptorand" "github.com/coder/coder/v2/enterprise/coderd/schedule" + "github.com/coder/coder/v2/provisionersdk/proto" "github.com/coder/coder/v2/testutil" "github.com/coder/quartz" ) @@ -979,6 +984,252 @@ func TestTemplateTTL(t *testing.T) { }) } +func TestTemplateUpdatePrebuilds(t *testing.T) { + t.Parallel() + + // Dormant auto-delete configured to 10 hours + dormantAutoDelete := 10 * time.Hour + + // TTL configured to 8 hours + ttl := 8 * time.Hour + + // Autostop configuration set to everyday at midnight + autostopWeekdays, err := codersdk.WeekdaysToBitmap(codersdk.AllDaysOfWeek) + require.NoError(t, err) + + // Autostart configuration set to everyday at midnight + autostartSchedule, err := cron.Weekly("CRON_TZ=UTC 0 0 * * *") + require.NoError(t, err) + autostartWeekdays, err := codersdk.WeekdaysToBitmap(codersdk.AllDaysOfWeek) + require.NoError(t, err) + + cases := []struct { + name string + templateSchedule agplschedule.TemplateScheduleOptions + workspaceUpdate func(*testing.T, context.Context, database.Store, time.Time, database.ClaimPrebuiltWorkspaceRow) + assertWorkspace func(*testing.T, context.Context, database.Store, time.Time, bool, database.Workspace) + }{ + { + name: "TemplateDormantAutoDeleteUpdatePrebuildAfterClaim", + templateSchedule: agplschedule.TemplateScheduleOptions{ + // Template level TimeTilDormantAutodelete set to 10 hours + TimeTilDormantAutoDelete: dormantAutoDelete, + }, + workspaceUpdate: func(t *testing.T, ctx context.Context, db database.Store, now time.Time, + workspace database.ClaimPrebuiltWorkspaceRow, + ) { + // When: the workspace is marked dormant + dormantWorkspace, err := db.UpdateWorkspaceDormantDeletingAt(ctx, database.UpdateWorkspaceDormantDeletingAtParams{ + ID: workspace.ID, + DormantAt: sql.NullTime{ + Time: now, + Valid: true, + }, + }) + require.NoError(t, err) + require.NotNil(t, dormantWorkspace.DormantAt) + }, + assertWorkspace: func(t *testing.T, ctx context.Context, db database.Store, now time.Time, + isPrebuild bool, workspace database.Workspace, + ) { + if isPrebuild { + // The unclaimed prebuild should have an empty DormantAt and DeletingAt + require.True(t, workspace.DormantAt.Time.IsZero()) + require.True(t, workspace.DeletingAt.Time.IsZero()) + } else { + // The claimed workspace should have its DormantAt and DeletingAt updated + require.False(t, workspace.DormantAt.Time.IsZero()) + require.False(t, workspace.DeletingAt.Time.IsZero()) + require.WithinDuration(t, now.UTC(), workspace.DormantAt.Time.UTC(), time.Second) + require.WithinDuration(t, now.Add(dormantAutoDelete).UTC(), workspace.DeletingAt.Time.UTC(), time.Second) + } + }, + }, + { + name: "TemplateTTLUpdatePrebuildAfterClaim", + templateSchedule: agplschedule.TemplateScheduleOptions{ + // Template level TTL can only be set if autostop is disabled for users + DefaultTTL: ttl, + UserAutostopEnabled: false, + }, + workspaceUpdate: func(t *testing.T, ctx context.Context, db database.Store, now time.Time, + workspace database.ClaimPrebuiltWorkspaceRow) { + }, + assertWorkspace: func(t *testing.T, ctx context.Context, db database.Store, now time.Time, + isPrebuild bool, workspace database.Workspace, + ) { + if isPrebuild { + // The unclaimed prebuild should have an empty TTL + require.Equal(t, sql.NullInt64{}, workspace.Ttl) + } else { + // The claimed workspace should have its TTL updated + require.Equal(t, sql.NullInt64{Int64: int64(ttl), Valid: true}, workspace.Ttl) + } + }, + }, + { + name: "TemplateAutostopUpdatePrebuildAfterClaim", + templateSchedule: agplschedule.TemplateScheduleOptions{ + // Template level Autostop set for everyday + AutostopRequirement: agplschedule.TemplateAutostopRequirement{ + DaysOfWeek: autostopWeekdays, + Weeks: 0, + }, + }, + workspaceUpdate: func(t *testing.T, ctx context.Context, db database.Store, now time.Time, + workspace database.ClaimPrebuiltWorkspaceRow) { + }, + assertWorkspace: func(t *testing.T, ctx context.Context, db database.Store, now time.Time, isPrebuild bool, workspace database.Workspace) { + if isPrebuild { + // The unclaimed prebuild should have an empty MaxDeadline + prebuildBuild, err := db.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID) + require.NoError(t, err) + require.True(t, prebuildBuild.MaxDeadline.IsZero()) + } else { + // The claimed workspace should have its MaxDeadline updated + workspaceBuild, err := db.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID) + require.NoError(t, err) + require.False(t, workspaceBuild.MaxDeadline.IsZero()) + } + }, + }, + { + name: "TemplateAutostartUpdatePrebuildAfterClaim", + templateSchedule: agplschedule.TemplateScheduleOptions{ + // Template level Autostart set for everyday + UserAutostartEnabled: true, + AutostartRequirement: agplschedule.TemplateAutostartRequirement{ + DaysOfWeek: autostartWeekdays, + }, + }, + workspaceUpdate: func(t *testing.T, ctx context.Context, db database.Store, now time.Time, workspace database.ClaimPrebuiltWorkspaceRow) { + // To compute NextStartAt, the workspace must have a valid autostart schedule + err = db.UpdateWorkspaceAutostart(ctx, database.UpdateWorkspaceAutostartParams{ + ID: workspace.ID, + AutostartSchedule: sql.NullString{ + String: autostartSchedule.String(), + Valid: true, + }, + }) + require.NoError(t, err) + }, + assertWorkspace: func(t *testing.T, ctx context.Context, db database.Store, now time.Time, isPrebuild bool, workspace database.Workspace) { + if isPrebuild { + // The unclaimed prebuild should have an empty NextStartAt + require.True(t, workspace.NextStartAt.Time.IsZero()) + } else { + // The claimed workspace should have its NextStartAt updated + require.False(t, workspace.NextStartAt.Time.IsZero()) + } + }, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + clock := quartz.NewMock(t) + clock.Set(dbtime.Now()) + + // Setup + var ( + logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + db, _ = dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure()) + ctx = testutil.Context(t, testutil.WaitLong) + user = dbgen.User(t, db, database.User{}) + ) + + // Setup the template schedule store + notifyEnq := notifications.NewNoopEnqueuer() + const userQuietHoursSchedule = "CRON_TZ=UTC 0 0 * * *" // midnight UTC + userQuietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(userQuietHoursSchedule, true) + require.NoError(t, err) + userQuietHoursStorePtr := &atomic.Pointer[agplschedule.UserQuietHoursScheduleStore]{} + userQuietHoursStorePtr.Store(&userQuietHoursStore) + templateScheduleStore := schedule.NewEnterpriseTemplateScheduleStore(userQuietHoursStorePtr, notifyEnq, logger, clock) + + // Given: a template and a template version with preset and a prebuilt workspace + presetID := uuid.New() + org := dbfake.Organization(t, db).Do() + tv := dbfake.TemplateVersion(t, db).Seed(database.TemplateVersion{ + OrganizationID: org.Org.ID, + CreatedBy: user.ID, + }).Preset(database.TemplateVersionPreset{ + ID: presetID, + DesiredInstances: sql.NullInt32{ + Int32: 1, + Valid: true, + }, + }).Do() + workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: database.PrebuildsSystemUserID, + TemplateID: tv.Template.ID, + OrganizationID: tv.Template.OrganizationID, + }).Seed(database.WorkspaceBuild{ + TemplateVersionID: tv.TemplateVersion.ID, + TemplateVersionPresetID: uuid.NullUUID{ + UUID: presetID, + Valid: true, + }, + }).WithAgent(func(agent []*proto.Agent) []*proto.Agent { + return agent + }).Do() + + // Mark the prebuilt workspace's agent as ready so the prebuild can be claimed + // nolint:gocritic + agentCtx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitLong)) + agent, err := db.GetWorkspaceAgentAndLatestBuildByAuthToken(agentCtx, uuid.MustParse(workspaceBuild.AgentToken)) + require.NoError(t, err) + err = db.UpdateWorkspaceAgentLifecycleStateByID(agentCtx, database.UpdateWorkspaceAgentLifecycleStateByIDParams{ + ID: agent.WorkspaceAgent.ID, + LifecycleState: database.WorkspaceAgentLifecycleStateReady, + }) + require.NoError(t, err) + + // Given: a prebuilt workspace + prebuild, err := db.GetWorkspaceByID(ctx, workspaceBuild.Workspace.ID) + require.NoError(t, err) + tc.assertWorkspace(t, ctx, db, clock.Now(), true, prebuild) + + // When: the template schedule is updated + _, err = templateScheduleStore.Set(ctx, db, tv.Template, tc.templateSchedule) + require.NoError(t, err) + + // Then: lifecycle parameters must remain unset while the prebuild is unclaimed + prebuild, err = db.GetWorkspaceByID(ctx, workspaceBuild.Workspace.ID) + require.NoError(t, err) + tc.assertWorkspace(t, ctx, db, clock.Now(), true, prebuild) + + // Given: the prebuilt workspace is claimed by a user + claimedWorkspace := dbgen.ClaimPrebuild( + t, db, + clock.Now(), + user.ID, + "claimedWorkspace-autostop", + presetID, + sql.NullString{}, + sql.NullTime{}, + sql.NullInt64{}) + require.Equal(t, prebuild.ID, claimedWorkspace.ID) + + // Given: the workspace level configurations are properly set in order to ensure the + // lifecycle parameters are updated + tc.workspaceUpdate(t, ctx, db, clock.Now(), claimedWorkspace) + + // When: the template schedule is updated + _, err = templateScheduleStore.Set(ctx, db, tv.Template, tc.templateSchedule) + require.NoError(t, err) + + // Then: the workspace should have its lifecycle parameters updated + workspace, err := db.GetWorkspaceByID(ctx, claimedWorkspace.ID) + require.NoError(t, err) + tc.assertWorkspace(t, ctx, db, clock.Now(), false, workspace) + }) + } +} + func must[V any](v V, err error) V { if err != nil { panic(err) From c4290201c3120185bdb59db33972c1c1c263166f Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Tue, 19 Aug 2025 15:54:42 +0200 Subject: [PATCH 2/6] fix(enterprise): update external agent instructions in cli (#19411) ### Description The command for agent instructions was incorrectly displayed in the CLI. --- enterprise/cli/externalworkspaces.go | 5 ++--- enterprise/cli/externalworkspaces_test.go | 25 ++++++++++++----------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/enterprise/cli/externalworkspaces.go b/enterprise/cli/externalworkspaces.go index 26bdeea2dffe7..081cbb765e170 100644 --- a/enterprise/cli/externalworkspaces.go +++ b/enterprise/cli/externalworkspaces.go @@ -243,15 +243,14 @@ func fetchExternalAgents(inv *serpent.Invocation, client *codersdk.Client, works // formatExternalAgent formats the instructions for an external agent. func formatExternalAgent(workspaceName string, externalAgents []externalAgent) string { var output strings.Builder - _, _ = output.WriteString(fmt.Sprintf("\nPlease run the following commands to attach external agent to the workspace %s:\n\n", cliui.Keyword(workspaceName))) + _, _ = output.WriteString(fmt.Sprintf("\nPlease run the following command to attach external agent to the workspace %s:\n\n", cliui.Keyword(workspaceName))) for i, agent := range externalAgents { if len(externalAgents) > 1 { _, _ = output.WriteString(fmt.Sprintf("For agent %s:\n", cliui.Keyword(agent.AgentName))) } - _, _ = output.WriteString(fmt.Sprintf("%s\n", pretty.Sprint(cliui.DefaultStyles.Code, fmt.Sprintf("export CODER_AGENT_TOKEN=%s", agent.AuthToken)))) - _, _ = output.WriteString(fmt.Sprintf("%s\n", pretty.Sprint(cliui.DefaultStyles.Code, fmt.Sprintf("curl -fsSL %s | sh", agent.InitScript)))) + _, _ = output.WriteString(fmt.Sprintf("%s\n", pretty.Sprint(cliui.DefaultStyles.Code, agent.InitScript))) if i < len(externalAgents)-1 { _, _ = output.WriteString("\n") diff --git a/enterprise/cli/externalworkspaces_test.go b/enterprise/cli/externalworkspaces_test.go index 6006cd1a1a8a2..9ce39c7c28afb 100644 --- a/enterprise/cli/externalworkspaces_test.go +++ b/enterprise/cli/externalworkspaces_test.go @@ -162,11 +162,11 @@ func TestExternalWorkspaces(t *testing.T) { pty.WriteLine("yes") // Expect the external agent instructions - pty.ExpectMatch("Please run the following commands to attach external agent") - pty.ExpectMatch("export CODER_AGENT_TOKEN=") - pty.ExpectMatch("curl -fsSL") + pty.ExpectMatch("Please run the following command to attach external agent") + pty.ExpectRegexMatch("curl -fsSL .* | CODER_AGENT_TOKEN=.* sh") - <-doneChan + ctx := testutil.Context(t, testutil.WaitLong) + testutil.TryReceive(ctx, t, doneChan) // Verify the workspace was created ws, err := member.WorkspaceByOwnerAndName(context.Background(), codersdk.Me, "my-external-workspace", codersdk.WorkspaceOptions{}) @@ -392,11 +392,12 @@ func TestExternalWorkspaces(t *testing.T) { assert.NoError(t, errC) close(done) }() - pty.ExpectMatch("Please run the following commands to attach external agent to the workspace") - pty.ExpectMatch("export CODER_AGENT_TOKEN=") - pty.ExpectMatch("curl -fsSL") + pty.ExpectMatch("Please run the following command to attach external agent to the workspace") + pty.ExpectRegexMatch("curl -fsSL .* | CODER_AGENT_TOKEN=.* sh") cancelFunc() - <-done + + ctx = testutil.Context(t, testutil.WaitLong) + testutil.TryReceive(ctx, t, done) }) t.Run("AgentInstructionsJSON", func(t *testing.T) { @@ -545,11 +546,11 @@ func TestExternalWorkspaces(t *testing.T) { pty.ExpectMatch("external-agent (linux, amd64)") // Expect the external agent instructions - pty.ExpectMatch("Please run the following commands to attach external agent") - pty.ExpectMatch("export CODER_AGENT_TOKEN=") - pty.ExpectMatch("curl -fsSL") + pty.ExpectMatch("Please run the following command to attach external agent") + pty.ExpectRegexMatch("curl -fsSL .* | CODER_AGENT_TOKEN=.* sh") - <-doneChan + ctx := testutil.Context(t, testutil.WaitLong) + testutil.TryReceive(ctx, t, doneChan) // Verify the workspace was created ws, err := member.WorkspaceByOwnerAndName(context.Background(), codersdk.Me, "my-external-workspace", codersdk.WorkspaceOptions{}) From 655377165b69b33276ee69a870f5d545b8f7c954 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 19 Aug 2025 14:56:37 +0100 Subject: [PATCH 3/6] feat(coderd): generate task names based on their prompt (#19335) Closes https://github.com/coder/coder/issues/18159 If an Anthropic API key is available, we call out to Claude to generate a task name based on the user-provided prompt instead of our random name generator. --- coderd/aitasks.go | 17 +++- coderd/taskname/taskname.go | 145 +++++++++++++++++++++++++++++++ coderd/taskname/taskname_test.go | 48 ++++++++++ go.mod | 2 +- 4 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 coderd/taskname/taskname.go create mode 100644 coderd/taskname/taskname_test.go diff --git a/coderd/aitasks.go b/coderd/aitasks.go index e1d72f264a025..f5d72beaf3903 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -10,11 +10,14 @@ import ( "github.com/google/uuid" + "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/taskname" "github.com/coder/coder/v2/codersdk" ) @@ -104,8 +107,20 @@ func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) { return } + taskName := req.Name + if anthropicAPIKey := taskname.GetAnthropicAPIKeyFromEnv(); anthropicAPIKey != "" { + anthropicModel := taskname.GetAnthropicModelFromEnv() + + generatedName, err := taskname.Generate(ctx, req.Prompt, taskname.WithAPIKey(anthropicAPIKey), taskname.WithModel(anthropicModel)) + if err != nil { + api.Logger.Error(ctx, "unable to generate task name", slog.Error(err)) + } else { + taskName = generatedName + } + } + createReq := codersdk.CreateWorkspaceRequest{ - Name: req.Name, + Name: taskName, TemplateVersionID: req.TemplateVersionID, TemplateVersionPresetID: req.TemplateVersionPresetID, RichParameterValues: []codersdk.WorkspaceBuildParameter{ diff --git a/coderd/taskname/taskname.go b/coderd/taskname/taskname.go new file mode 100644 index 0000000000000..970e5ad67b2a0 --- /dev/null +++ b/coderd/taskname/taskname.go @@ -0,0 +1,145 @@ +package taskname + +import ( + "context" + "io" + "os" + + "github.com/anthropics/anthropic-sdk-go" + anthropicoption "github.com/anthropics/anthropic-sdk-go/option" + "golang.org/x/xerrors" + + "github.com/coder/aisdk-go" + "github.com/coder/coder/v2/codersdk" +) + +const ( + defaultModel = anthropic.ModelClaude3_5HaikuLatest + systemPrompt = `Generate a short workspace name from this AI task prompt. + +Requirements: +- Only lowercase letters, numbers, and hyphens +- Start with "task-" +- End with a random number between 0-99 +- Maximum 32 characters total +- Descriptive of the main task + +Examples: +- "Help me debug a Python script" → "task-python-debug-12" +- "Create a React dashboard component" → "task-react-dashboard-93" +- "Analyze sales data from Q3" → "task-analyze-q3-sales-37" +- "Set up CI/CD pipeline" → "task-setup-cicd-44" + +If you cannot create a suitable name: +- Respond with "task-unnamed" +- Do not end with a random number` +) + +var ( + ErrNoAPIKey = xerrors.New("no api key provided") + ErrNoNameGenerated = xerrors.New("no task name generated") +) + +type options struct { + apiKey string + model anthropic.Model +} + +type Option func(o *options) + +func WithAPIKey(apiKey string) Option { + return func(o *options) { + o.apiKey = apiKey + } +} + +func WithModel(model anthropic.Model) Option { + return func(o *options) { + o.model = model + } +} + +func GetAnthropicAPIKeyFromEnv() string { + return os.Getenv("ANTHROPIC_API_KEY") +} + +func GetAnthropicModelFromEnv() anthropic.Model { + return anthropic.Model(os.Getenv("ANTHROPIC_MODEL")) +} + +func Generate(ctx context.Context, prompt string, opts ...Option) (string, error) { + o := options{} + for _, opt := range opts { + opt(&o) + } + + if o.model == "" { + o.model = defaultModel + } + if o.apiKey == "" { + return "", ErrNoAPIKey + } + + conversation := []aisdk.Message{ + { + Role: "system", + Parts: []aisdk.Part{{ + Type: aisdk.PartTypeText, + Text: systemPrompt, + }}, + }, + { + Role: "user", + Parts: []aisdk.Part{{ + Type: aisdk.PartTypeText, + Text: prompt, + }}, + }, + } + + anthropicOptions := anthropic.DefaultClientOptions() + anthropicOptions = append(anthropicOptions, anthropicoption.WithAPIKey(o.apiKey)) + anthropicClient := anthropic.NewClient(anthropicOptions...) + + stream, err := anthropicDataStream(ctx, anthropicClient, o.model, conversation) + if err != nil { + return "", xerrors.Errorf("create anthropic data stream: %w", err) + } + + var acc aisdk.DataStreamAccumulator + stream = stream.WithAccumulator(&acc) + + if err := stream.Pipe(io.Discard); err != nil { + return "", xerrors.Errorf("pipe data stream") + } + + if len(acc.Messages()) == 0 { + return "", ErrNoNameGenerated + } + + generatedName := acc.Messages()[0].Content + + if err := codersdk.NameValid(generatedName); err != nil { + return "", xerrors.Errorf("generated name %v not valid: %w", generatedName, err) + } + + if generatedName == "task-unnamed" { + return "", ErrNoNameGenerated + } + + return generatedName, nil +} + +func anthropicDataStream(ctx context.Context, client anthropic.Client, model anthropic.Model, input []aisdk.Message) (aisdk.DataStream, error) { + messages, system, err := aisdk.MessagesToAnthropic(input) + if err != nil { + return nil, xerrors.Errorf("convert messages to anthropic format: %w", err) + } + + return aisdk.AnthropicToDataStream(client.Messages.NewStreaming(ctx, anthropic.MessageNewParams{ + Model: model, + MaxTokens: 24, + System: system, + Messages: messages, + })), nil +} diff --git a/coderd/taskname/taskname_test.go b/coderd/taskname/taskname_test.go new file mode 100644 index 0000000000000..0737621b8f4eb --- /dev/null +++ b/coderd/taskname/taskname_test.go @@ -0,0 +1,48 @@ +package taskname_test + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/taskname" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +const ( + anthropicEnvVar = "ANTHROPIC_API_KEY" +) + +func TestGenerateTaskName(t *testing.T) { + t.Parallel() + + t.Run("Fallback", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + + name, err := taskname.Generate(ctx, "Some random prompt") + require.ErrorIs(t, err, taskname.ErrNoAPIKey) + require.Equal(t, "", name) + }) + + t.Run("Anthropic", func(t *testing.T) { + t.Parallel() + + apiKey := os.Getenv(anthropicEnvVar) + if apiKey == "" { + t.Skipf("Skipping test as %s not set", anthropicEnvVar) + } + + ctx := testutil.Context(t, testutil.WaitShort) + + name, err := taskname.Generate(ctx, "Create a finance planning app", taskname.WithAPIKey(apiKey)) + require.NoError(t, err) + require.NotEqual(t, "", name) + + err = codersdk.NameValid(name) + require.NoError(t, err, "name should be valid") + }) +} diff --git a/go.mod b/go.mod index e10c7a248db7e..6d703cdd1245e 100644 --- a/go.mod +++ b/go.mod @@ -477,6 +477,7 @@ require ( ) require ( + github.com/anthropics/anthropic-sdk-go v1.4.0 github.com/brianvoe/gofakeit/v7 v7.3.0 github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225 github.com/coder/aisdk-go v0.0.9 @@ -500,7 +501,6 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.50.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.50.0 // indirect github.com/Masterminds/semver/v3 v3.3.1 // indirect - github.com/anthropics/anthropic-sdk-go v1.4.0 // indirect github.com/aquasecurity/go-version v0.0.1 // indirect github.com/aquasecurity/trivy v0.58.2 // indirect github.com/aws/aws-sdk-go v1.55.7 // indirect From b6abcba9429aa446bbddabc634dfaeaa71972cef Mon Sep 17 00:00:00 2001 From: Phorcys <57866459+phorcys420@users.noreply.github.com> Date: Tue, 19 Aug 2025 17:14:25 +0200 Subject: [PATCH 4/6] chore: correct template API docs (#19228) --- coderd/apidoc/docs.go | 103 ++++++++++++++++++++++++++++++-- coderd/apidoc/swagger.json | 101 +++++++++++++++++++++++++++++-- coderd/templates.go | 10 ++-- docs/reference/api/schemas.md | 65 ++++++++++++++++++++ docs/reference/api/templates.md | 50 ++++++++++++++-- 5 files changed, 312 insertions(+), 17 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index f0177a23924e4..96034721a5af2 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -5173,8 +5173,8 @@ const docTemplate = `{ "tags": [ "Templates" ], - "summary": "Get template metadata by ID", - "operationId": "get-template-metadata-by-id", + "summary": "Get template settings by ID", + "operationId": "get-template-settings-by-id", "parameters": [ { "type": "string", @@ -5233,14 +5233,17 @@ const docTemplate = `{ "CoderSessionToken": [] } ], + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ "Templates" ], - "summary": "Update template metadata by ID", - "operationId": "update-template-metadata-by-id", + "summary": "Update template settings by ID", + "operationId": "update-template-settings-by-id", "parameters": [ { "type": "string", @@ -5249,6 +5252,15 @@ const docTemplate = `{ "name": "template", "in": "path", "required": true + }, + { + "description": "Patch template settings request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateTemplateMeta" + } } ], "responses": { @@ -17304,6 +17316,89 @@ const docTemplate = `{ } } }, + "codersdk.UpdateTemplateMeta": { + "type": "object", + "properties": { + "activity_bump_ms": { + "description": "ActivityBumpMillis allows optionally specifying the activity bump\nduration for all workspaces created from this template. Defaults to 1h\nbut can be set to 0 to disable activity bumping.", + "type": "integer" + }, + "allow_user_autostart": { + "type": "boolean" + }, + "allow_user_autostop": { + "type": "boolean" + }, + "allow_user_cancel_workspace_jobs": { + "type": "boolean" + }, + "autostart_requirement": { + "$ref": "#/definitions/codersdk.TemplateAutostartRequirement" + }, + "autostop_requirement": { + "description": "AutostopRequirement and AutostartRequirement can only be set if your license\nincludes the advanced template scheduling feature. If you attempt to set this\nvalue while unlicensed, it will be ignored.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.TemplateAutostopRequirement" + } + ] + }, + "cors_behavior": { + "$ref": "#/definitions/codersdk.CORSBehavior" + }, + "default_ttl_ms": { + "type": "integer" + }, + "deprecation_message": { + "description": "DeprecationMessage if set, will mark the template as deprecated and block\nany new workspaces from using this template.\nIf passed an empty string, will remove the deprecated message, making\nthe template usable for new workspaces again.", + "type": "string" + }, + "description": { + "type": "string" + }, + "disable_everyone_group_access": { + "description": "DisableEveryoneGroupAccess allows optionally disabling the default\nbehavior of granting the 'everyone' group access to use the template.\nIf this is set to true, the template will not be available to all users,\nand must be explicitly granted to users or groups in the permissions settings\nof the template.", + "type": "boolean" + }, + "display_name": { + "type": "string" + }, + "failure_ttl_ms": { + "type": "integer" + }, + "icon": { + "type": "string" + }, + "max_port_share_level": { + "$ref": "#/definitions/codersdk.WorkspaceAgentPortShareLevel" + }, + "name": { + "type": "string" + }, + "require_active_version": { + "description": "RequireActiveVersion mandates workspaces built using this template\nuse the active version of the template. This option has no\neffect on template admins.", + "type": "boolean" + }, + "time_til_dormant_autodelete_ms": { + "type": "integer" + }, + "time_til_dormant_ms": { + "type": "integer" + }, + "update_workspace_dormant_at": { + "description": "UpdateWorkspaceDormant updates the dormant_at field of workspaces spawned\nfrom the template. This is useful for preventing dormant workspaces being immediately\ndeleted when updating the dormant_ttl field to a new, shorter value.", + "type": "boolean" + }, + "update_workspace_last_used_at": { + "description": "UpdateWorkspaceLastUsedAt updates the last_used_at field of workspaces\nspawned from the template. This is useful for preventing workspaces being\nimmediately locked when updating the inactivity_ttl field to a new, shorter\nvalue.", + "type": "boolean" + }, + "use_classic_parameter_flow": { + "description": "UseClassicParameterFlow is a flag that switches the default behavior to use the classic\nparameter flow when creating a workspace. This only affects deployments with the experiment\n\"dynamic-parameters\" enabled. This setting will live for a period after the experiment is\nmade the default.\nAn \"opt-out\" is present in case the new feature breaks some existing templates.", + "type": "boolean" + } + } + }, "codersdk.UpdateUserAppearanceSettingsRequest": { "type": "object", "required": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 87d7d48def404..107943e186c40 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -4556,8 +4556,8 @@ ], "produces": ["application/json"], "tags": ["Templates"], - "summary": "Get template metadata by ID", - "operationId": "get-template-metadata-by-id", + "summary": "Get template settings by ID", + "operationId": "get-template-settings-by-id", "parameters": [ { "type": "string", @@ -4612,10 +4612,11 @@ "CoderSessionToken": [] } ], + "consumes": ["application/json"], "produces": ["application/json"], "tags": ["Templates"], - "summary": "Update template metadata by ID", - "operationId": "update-template-metadata-by-id", + "summary": "Update template settings by ID", + "operationId": "update-template-settings-by-id", "parameters": [ { "type": "string", @@ -4624,6 +4625,15 @@ "name": "template", "in": "path", "required": true + }, + { + "description": "Patch template settings request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateTemplateMeta" + } } ], "responses": { @@ -15797,6 +15807,89 @@ } } }, + "codersdk.UpdateTemplateMeta": { + "type": "object", + "properties": { + "activity_bump_ms": { + "description": "ActivityBumpMillis allows optionally specifying the activity bump\nduration for all workspaces created from this template. Defaults to 1h\nbut can be set to 0 to disable activity bumping.", + "type": "integer" + }, + "allow_user_autostart": { + "type": "boolean" + }, + "allow_user_autostop": { + "type": "boolean" + }, + "allow_user_cancel_workspace_jobs": { + "type": "boolean" + }, + "autostart_requirement": { + "$ref": "#/definitions/codersdk.TemplateAutostartRequirement" + }, + "autostop_requirement": { + "description": "AutostopRequirement and AutostartRequirement can only be set if your license\nincludes the advanced template scheduling feature. If you attempt to set this\nvalue while unlicensed, it will be ignored.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.TemplateAutostopRequirement" + } + ] + }, + "cors_behavior": { + "$ref": "#/definitions/codersdk.CORSBehavior" + }, + "default_ttl_ms": { + "type": "integer" + }, + "deprecation_message": { + "description": "DeprecationMessage if set, will mark the template as deprecated and block\nany new workspaces from using this template.\nIf passed an empty string, will remove the deprecated message, making\nthe template usable for new workspaces again.", + "type": "string" + }, + "description": { + "type": "string" + }, + "disable_everyone_group_access": { + "description": "DisableEveryoneGroupAccess allows optionally disabling the default\nbehavior of granting the 'everyone' group access to use the template.\nIf this is set to true, the template will not be available to all users,\nand must be explicitly granted to users or groups in the permissions settings\nof the template.", + "type": "boolean" + }, + "display_name": { + "type": "string" + }, + "failure_ttl_ms": { + "type": "integer" + }, + "icon": { + "type": "string" + }, + "max_port_share_level": { + "$ref": "#/definitions/codersdk.WorkspaceAgentPortShareLevel" + }, + "name": { + "type": "string" + }, + "require_active_version": { + "description": "RequireActiveVersion mandates workspaces built using this template\nuse the active version of the template. This option has no\neffect on template admins.", + "type": "boolean" + }, + "time_til_dormant_autodelete_ms": { + "type": "integer" + }, + "time_til_dormant_ms": { + "type": "integer" + }, + "update_workspace_dormant_at": { + "description": "UpdateWorkspaceDormant updates the dormant_at field of workspaces spawned\nfrom the template. This is useful for preventing dormant workspaces being immediately\ndeleted when updating the dormant_ttl field to a new, shorter value.", + "type": "boolean" + }, + "update_workspace_last_used_at": { + "description": "UpdateWorkspaceLastUsedAt updates the last_used_at field of workspaces\nspawned from the template. This is useful for preventing workspaces being\nimmediately locked when updating the inactivity_ttl field to a new, shorter\nvalue.", + "type": "boolean" + }, + "use_classic_parameter_flow": { + "description": "UseClassicParameterFlow is a flag that switches the default behavior to use the classic\nparameter flow when creating a workspace. This only affects deployments with the experiment\n\"dynamic-parameters\" enabled. This setting will live for a period after the experiment is\nmade the default.\nAn \"opt-out\" is present in case the new feature breaks some existing templates.", + "type": "boolean" + } + } + }, "codersdk.UpdateUserAppearanceSettingsRequest": { "type": "object", "required": ["terminal_font", "theme_preference"], diff --git a/coderd/templates.go b/coderd/templates.go index 16ab5b3fa37a5..9202fc48234a6 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -38,8 +38,8 @@ import ( // Returns a single template. // -// @Summary Get template metadata by ID -// @ID get-template-metadata-by-id +// @Summary Get template settings by ID +// @ID get-template-settings-by-id // @Security CoderSessionToken // @Produce json // @Tags Templates @@ -629,12 +629,14 @@ func (api *API) templateByOrganizationAndName(rw http.ResponseWriter, r *http.Re httpapi.Write(ctx, rw, http.StatusOK, api.convertTemplate(template)) } -// @Summary Update template metadata by ID -// @ID update-template-metadata-by-id +// @Summary Update template settings by ID +// @ID update-template-settings-by-id // @Security CoderSessionToken +// @Accept json // @Produce json // @Tags Templates // @Param template path string true "Template ID" format(uuid) +// @Param request body codersdk.UpdateTemplateMeta true "Patch template settings request" // @Success 200 {object} codersdk.Template // @Router /templates/{template} [patch] func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index a886ae0dbc795..c5e99fcdbfc72 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -8087,6 +8087,71 @@ Restarts will only happen on weekdays in this list on weeks which line up with W | `user_perms` | object | false | | User perms should be a mapping of user ID to role. The user ID must be the uuid of the user, not a username or email address. | | » `[any property]` | [codersdk.TemplateRole](#codersdktemplaterole) | false | | | +## codersdk.UpdateTemplateMeta + +```json +{ + "activity_bump_ms": 0, + "allow_user_autostart": true, + "allow_user_autostop": true, + "allow_user_cancel_workspace_jobs": true, + "autostart_requirement": { + "days_of_week": [ + "monday" + ] + }, + "autostop_requirement": { + "days_of_week": [ + "monday" + ], + "weeks": 0 + }, + "cors_behavior": "simple", + "default_ttl_ms": 0, + "deprecation_message": "string", + "description": "string", + "disable_everyone_group_access": true, + "display_name": "string", + "failure_ttl_ms": 0, + "icon": "string", + "max_port_share_level": "owner", + "name": "string", + "require_active_version": true, + "time_til_dormant_autodelete_ms": 0, + "time_til_dormant_ms": 0, + "update_workspace_dormant_at": true, + "update_workspace_last_used_at": true, + "use_classic_parameter_flow": true +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|------------------------------------|--------------------------------------------------------------------------------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `activity_bump_ms` | integer | false | | Activity bump ms allows optionally specifying the activity bump duration for all workspaces created from this template. Defaults to 1h but can be set to 0 to disable activity bumping. | +| `allow_user_autostart` | boolean | false | | | +| `allow_user_autostop` | boolean | false | | | +| `allow_user_cancel_workspace_jobs` | boolean | false | | | +| `autostart_requirement` | [codersdk.TemplateAutostartRequirement](#codersdktemplateautostartrequirement) | false | | | +| `autostop_requirement` | [codersdk.TemplateAutostopRequirement](#codersdktemplateautostoprequirement) | false | | Autostop requirement and AutostartRequirement can only be set if your license includes the advanced template scheduling feature. If you attempt to set this value while unlicensed, it will be ignored. | +| `cors_behavior` | [codersdk.CORSBehavior](#codersdkcorsbehavior) | false | | | +| `default_ttl_ms` | integer | false | | | +| `deprecation_message` | string | false | | Deprecation message if set, will mark the template as deprecated and block any new workspaces from using this template. If passed an empty string, will remove the deprecated message, making the template usable for new workspaces again. | +| `description` | string | false | | | +| `disable_everyone_group_access` | boolean | false | | Disable everyone group access allows optionally disabling the default behavior of granting the 'everyone' group access to use the template. If this is set to true, the template will not be available to all users, and must be explicitly granted to users or groups in the permissions settings of the template. | +| `display_name` | string | false | | | +| `failure_ttl_ms` | integer | false | | | +| `icon` | string | false | | | +| `max_port_share_level` | [codersdk.WorkspaceAgentPortShareLevel](#codersdkworkspaceagentportsharelevel) | false | | | +| `name` | string | false | | | +| `require_active_version` | boolean | false | | Require active version mandates workspaces built using this template use the active version of the template. This option has no effect on template admins. | +| `time_til_dormant_autodelete_ms` | integer | false | | | +| `time_til_dormant_ms` | integer | false | | | +| `update_workspace_dormant_at` | boolean | false | | Update workspace dormant at updates the dormant_at field of workspaces spawned from the template. This is useful for preventing dormant workspaces being immediately deleted when updating the dormant_ttl field to a new, shorter value. | +| `update_workspace_last_used_at` | boolean | false | | Update workspace last used at updates the last_used_at field of workspaces spawned from the template. This is useful for preventing workspaces being immediately locked when updating the inactivity_ttl field to a new, shorter value. | +| `use_classic_parameter_flow` | boolean | false | | Use classic parameter flow is a flag that switches the default behavior to use the classic parameter flow when creating a workspace. This only affects deployments with the experiment "dynamic-parameters" enabled. This setting will live for a period after the experiment is made the default. An "opt-out" is present in case the new feature breaks some existing templates. | + ## codersdk.UpdateUserAppearanceSettingsRequest ```json diff --git a/docs/reference/api/templates.md b/docs/reference/api/templates.md index f3df204750ca6..db5213bdf8ef5 100644 --- a/docs/reference/api/templates.md +++ b/docs/reference/api/templates.md @@ -955,7 +955,7 @@ Status Code **200** To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Get template metadata by ID +## Get template settings by ID ### Code samples @@ -1086,24 +1086,64 @@ curl -X DELETE http://coder-server:8080/api/v2/templates/{template} \ To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Update template metadata by ID +## Update template settings by ID ### Code samples ```shell # Example request using curl curl -X PATCH http://coder-server:8080/api/v2/templates/{template} \ + -H 'Content-Type: application/json' \ -H 'Accept: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` `PATCH /templates/{template}` +> Body parameter + +```json +{ + "activity_bump_ms": 0, + "allow_user_autostart": true, + "allow_user_autostop": true, + "allow_user_cancel_workspace_jobs": true, + "autostart_requirement": { + "days_of_week": [ + "monday" + ] + }, + "autostop_requirement": { + "days_of_week": [ + "monday" + ], + "weeks": 0 + }, + "cors_behavior": "simple", + "default_ttl_ms": 0, + "deprecation_message": "string", + "description": "string", + "disable_everyone_group_access": true, + "display_name": "string", + "failure_ttl_ms": 0, + "icon": "string", + "max_port_share_level": "owner", + "name": "string", + "require_active_version": true, + "time_til_dormant_autodelete_ms": 0, + "time_til_dormant_ms": 0, + "update_workspace_dormant_at": true, + "update_workspace_last_used_at": true, + "use_classic_parameter_flow": true +} +``` + ### Parameters -| Name | In | Type | Required | Description | -|------------|------|--------------|----------|-------------| -| `template` | path | string(uuid) | true | Template ID | +| Name | In | Type | Required | Description | +|------------|------|----------------------------------------------------------------------|----------|---------------------------------| +| `template` | path | string(uuid) | true | Template ID | +| `body` | body | [codersdk.UpdateTemplateMeta](schemas.md#codersdkupdatetemplatemeta) | true | Patch template settings request | ### Example responses From 9e5c83ae0df658a328935ad363dc94d558a82e9e Mon Sep 17 00:00:00 2001 From: Callum Styan Date: Tue, 19 Aug 2025 09:24:40 -0700 Subject: [PATCH 5/6] fix: fix flakes in TestWorkspaceAutobuild due to incorrect tick time (#19398) we missed these in the previous PR, we find `tickTime2` and pass it to the `tickCh`, but we were incorrectly passing `tickTime` to `UpdateProvisionerLastSeenAt` in some places Signed-off-by: Callum Styan --- enterprise/coderd/workspaces_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index dad24460068cd..97a223b17751c 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -1277,7 +1277,7 @@ func TestWorkspaceAutobuild(t *testing.T) { // We should see the workspace get stopped now. tickTime2 := ws.LastUsedAt.Add(inactiveTTL * 2) - coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime2) tickCh <- tickTime2 stats = <-statsCh require.Len(t, stats.Errors, 0) From f2ee89c36ab53cc9b952ab8586695c671a8c9744 Mon Sep 17 00:00:00 2001 From: Callum Styan Date: Tue, 19 Aug 2025 10:24:40 -0700 Subject: [PATCH 6/6] fix: fix more `TestWorkspaceAutobuild` flakes (#19417) made these commits yesterday but apparently I forgot to push so they got missed in https://github.com/coder/coder/pull/19398 --------- Signed-off-by: Callum Styan --- enterprise/coderd/workspaces_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index 97a223b17751c..7004653e4ed60 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -1122,7 +1122,7 @@ func TestWorkspaceAutobuild(t *testing.T) { // Simulate the workspace being dormant beyond the threshold. tickTime2 := ws.DormantAt.Add(2 * transitionTTL) - coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime2) ticker <- tickTime2 stats = <-statCh require.Len(t, stats.Transitions, 1) @@ -1481,7 +1481,7 @@ func TestWorkspaceAutobuild(t *testing.T) { // Force an autostart transition again. tickTime2 := sched.Next(firstBuild.CreatedAt) - coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime2) tickCh <- tickTime2 stats = <-statsCh require.Len(t, stats.Errors, 0)