diff --git a/coderd/chatd/chattool/createworkspace.go b/coderd/chatd/chattool/createworkspace.go index 28eacbb27d..f92f9ed4f6 100644 --- a/coderd/chatd/chattool/createworkspace.go +++ b/coderd/chatd/chattool/createworkspace.go @@ -140,8 +140,30 @@ func CreateWorkspace(options CreateWorkspaceOptions) fantasy.AgentTool { ctx = ownerCtx } + var ttlMs *int64 + if options.DB != nil { + raw, err := options.DB.GetChatWorkspaceTTL(ctx) + if err != nil { + options.Logger.Error(ctx, "failed to read chat workspace TTL setting, using template default", + slog.Error(err), + ) + } else { + d, parseErr := codersdk.ParseChatWorkspaceTTL(raw) + if parseErr != nil { + options.Logger.Warn(ctx, "invalid chat workspace TTL setting, using template default", + slog.F("raw", raw), + slog.Error(parseErr), + ) + } else if d > 0 { + ms := d.Milliseconds() + ttlMs = &ms + } + } + } + createReq := codersdk.CreateWorkspaceRequest{ TemplateID: templateID, + TTLMillis: ttlMs, } // Resolve workspace name. diff --git a/coderd/chatd/chattool/createworkspace_test.go b/coderd/chatd/chattool/createworkspace_test.go index d8c38c55bf..f1174f1b87 100644 --- a/coderd/chatd/chattool/createworkspace_test.go +++ b/coderd/chatd/chattool/createworkspace_test.go @@ -2,14 +2,22 @@ package chattool //nolint:testpackage // Uses internal symbols. import ( "context" + "fmt" + "sync" "testing" + "time" + "charm.land/fantasy" "github.com/google/uuid" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" + "golang.org/x/xerrors" + "cdr.dev/slog/v3/sloggers/slogtest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbmock" + "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/workspacesdk" ) @@ -109,6 +117,117 @@ func TestWaitForAgentReady(t *testing.T) { }) } +func TestCreateWorkspace_GlobalTTL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ttlReturn string + ttlErr error + wantTTLMs *int64 + }{ + { + name: "PositiveTTL", + ttlReturn: "2h", + wantTTLMs: ptr.Ref(int64(2 * time.Hour / time.Millisecond)), + }, + { + name: "ZeroTTLUsesTemplateDefault", + ttlReturn: "0s", + wantTTLMs: nil, + }, + { + name: "DBError_FallsBackToNil", + ttlReturn: "", + ttlErr: xerrors.New("db error"), + wantTTLMs: nil, + }, + { + name: "InvalidStoredValue_FallsBackToNil", + ttlReturn: "not-a-duration", + wantTTLMs: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + + ownerID := uuid.New() + templateID := uuid.New() + workspaceID := uuid.New() + jobID := uuid.New() + + db.EXPECT(). + GetAuthorizationUserRoles(gomock.Any(), ownerID). + Return(database.GetAuthorizationUserRolesRow{ + ID: ownerID, + Roles: []string{}, + Groups: []string{}, + Status: database.UserStatusActive, + }, nil) + + db.EXPECT(). + GetChatWorkspaceTTL(gomock.Any()). + Return(tc.ttlReturn, tc.ttlErr) + + db.EXPECT(). + GetLatestWorkspaceBuildByWorkspaceID(gomock.Any(), workspaceID). + Return(database.WorkspaceBuild{ + WorkspaceID: workspaceID, + JobID: jobID, + }, nil) + db.EXPECT(). + GetProvisionerJobByID(gomock.Any(), jobID). + Return(database.ProvisionerJob{ + ID: jobID, + JobStatus: database.ProvisionerJobStatusSucceeded, + }, nil) + + db.EXPECT(). + GetWorkspaceAgentsInLatestBuildByWorkspaceID(gomock.Any(), workspaceID). + Return([]database.WorkspaceAgent{}, nil) + + var capturedReq codersdk.CreateWorkspaceRequest + createFn := func(_ context.Context, _ uuid.UUID, req codersdk.CreateWorkspaceRequest) (codersdk.Workspace, error) { + capturedReq = req + return codersdk.Workspace{ + ID: workspaceID, + Name: req.Name, + OwnerName: "testuser", + }, nil + } + + tool := CreateWorkspace(CreateWorkspaceOptions{ + DB: db, + OwnerID: ownerID, + ChatID: uuid.Nil, + CreateFn: createFn, + WorkspaceMu: &sync.Mutex{}, + Logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), + }) + + input := fmt.Sprintf(`{"template_id":%q,"name":"test-ws-%s"}`, templateID.String(), tc.name) + resp, err := tool.Run(context.Background(), fantasy.ToolCall{ + ID: "call-1", + Name: "create_workspace", + Input: input, + }) + require.NoError(t, err) + require.NotEmpty(t, resp.Content) + + if tc.wantTTLMs != nil { + require.NotNil(t, capturedReq.TTLMillis) + require.Equal(t, *tc.wantTTLMs, *capturedReq.TTLMillis) + } else { + require.Nil(t, capturedReq.TTLMillis) + } + }) + } +} + func TestCheckExistingWorkspace_DeletedWorkspace(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) diff --git a/coderd/chats.go b/coderd/chats.go index 566158f6e4..5eb66bce52 100644 --- a/coderd/chats.go +++ b/coderd/chats.go @@ -2665,6 +2665,91 @@ func (api *API) putChatDesktopEnabled(rw http.ResponseWriter, r *http.Request) { rw.WriteHeader(http.StatusNoContent) } +// EXPERIMENTAL: this endpoint is experimental and is subject to change. +// +//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler. +func (api *API) getChatWorkspaceTTL(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + raw, err := api.Database.GetChatWorkspaceTTL(ctx) + if err != nil { + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching workspace TTL setting.", + Detail: err.Error(), + }) + return + } + // Validate/default the stored value so callers always receive a + // well-formed duration string. + d, err := codersdk.ParseChatWorkspaceTTL(raw) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Stored workspace TTL is invalid.", + Detail: err.Error(), + }) + return + } + httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatWorkspaceTTLResponse{ + WorkspaceTTLMillis: d.Milliseconds(), + }) +} + +// EXPERIMENTAL: this endpoint is experimental and is subject to change. +func (api *API) putChatWorkspaceTTL(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { + httpapi.Forbidden(rw) + return + } + + var req codersdk.UpdateChatWorkspaceTTLRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + // Validate before converting to avoid int64 overflow in the + // multiplication by time.Millisecond. + if req.WorkspaceTTLMillis < 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Workspace TTL must be non-negative.", + }) + return + } + + // Convert milliseconds to duration. + d := time.Duration(req.WorkspaceTTLMillis) * time.Millisecond + + // Technically a duplication of validWorkspaceTTL but this is not scoped to templates. + if d > 0 && d < ttlMinimum { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Workspace TTL must not be less than 1 minute.", + }) + return + } + if d > ttlMaximum { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Workspace TTL must not exceed 30 days.", + }) + return + } + + // Store the canonicalized duration string. + if err := api.Database.UpsertChatWorkspaceTTL(ctx, d.String()); httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } else if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error updating workspace TTL setting.", + Detail: err.Error(), + }) + return + } + rw.WriteHeader(http.StatusNoContent) +} + // EXPERIMENTAL: this endpoint is experimental and is subject to change. // //nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler. diff --git a/coderd/chats_test.go b/coderd/chats_test.go index 5e4c11e3bf..cc381d3567 100644 --- a/coderd/chats_test.go +++ b/coderd/chats_test.go @@ -4775,6 +4775,94 @@ func TestChatDesktopEnabled(t *testing.T) { }) } +func TestChatWorkspaceTTL(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + adminClient := newChatClient(t) + firstUser := coderdtest.CreateFirstUser(t, adminClient) + memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) + anonClient := codersdk.New(adminClient.URL) + + // Default value is 0 (disabled) when nothing has been configured. + resp, err := adminClient.GetChatWorkspaceTTL(ctx) + require.NoError(t, err, "get default") + require.Equal(t, int64(0), resp.WorkspaceTTLMillis, "default should be 0") + + // Admin can set a positive TTL (2h = 7_200_000 ms). + err = adminClient.UpdateChatWorkspaceTTL(ctx, codersdk.UpdateChatWorkspaceTTLRequest{ + WorkspaceTTLMillis: 7_200_000, + }) + require.NoError(t, err, "admin set 2h") + + resp, err = adminClient.GetChatWorkspaceTTL(ctx) + require.NoError(t, err, "get after set") + require.Equal(t, int64(7_200_000), resp.WorkspaceTTLMillis, "should return 7200000 ms (2h)") + + // Non-admin can read the value. + resp, err = memberClient.GetChatWorkspaceTTL(ctx) + require.NoError(t, err, "member get") + require.Equal(t, int64(7_200_000), resp.WorkspaceTTLMillis, "member should see same value") + + // Admin can set back to zero (disabled / template default). + err = adminClient.UpdateChatWorkspaceTTL(ctx, codersdk.UpdateChatWorkspaceTTLRequest{ + WorkspaceTTLMillis: 0, + }) + require.NoError(t, err, "admin set 0") + + resp, err = adminClient.GetChatWorkspaceTTL(ctx) + require.NoError(t, err, "get after zero") + require.Equal(t, int64(0), resp.WorkspaceTTLMillis, "should be 0 after reset") + + // Non-admin write is forbidden. + err = memberClient.UpdateChatWorkspaceTTL(ctx, codersdk.UpdateChatWorkspaceTTLRequest{ + WorkspaceTTLMillis: 3_600_000, + }) + requireSDKError(t, err, http.StatusForbidden) + + // Unauthenticated read is rejected. + _, err = anonClient.GetChatWorkspaceTTL(ctx) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr, "anon get") + require.Equal(t, http.StatusUnauthorized, sdkErr.StatusCode(), "anon should get 401") + + // Validation: negative duration. + err = adminClient.UpdateChatWorkspaceTTL(ctx, codersdk.UpdateChatWorkspaceTTLRequest{ + WorkspaceTTLMillis: -3_600_000, + }) + requireSDKError(t, err, http.StatusBadRequest) + + // Validation: less than 1 minute (30s = 30_000 ms). + err = adminClient.UpdateChatWorkspaceTTL(ctx, codersdk.UpdateChatWorkspaceTTLRequest{ + WorkspaceTTLMillis: 30_000, + }) + requireSDKError(t, err, http.StatusBadRequest) + + // Boundary: just under 1 minute should be rejected (59_999 ms). + err = adminClient.UpdateChatWorkspaceTTL(ctx, codersdk.UpdateChatWorkspaceTTLRequest{ + WorkspaceTTLMillis: 59_999, + }) + requireSDKError(t, err, http.StatusBadRequest) + + // Boundary: exactly 1 minute should succeed (60_000 ms). + err = adminClient.UpdateChatWorkspaceTTL(ctx, codersdk.UpdateChatWorkspaceTTLRequest{ + WorkspaceTTLMillis: 60_000, + }) + require.NoError(t, err, "exactly 1 minute should be accepted") + + // Boundary: exactly 30 days should succeed (720h = 2_592_000_000 ms). + err = adminClient.UpdateChatWorkspaceTTL(ctx, codersdk.UpdateChatWorkspaceTTLRequest{ + WorkspaceTTLMillis: 2_592_000_000, + }) + require.NoError(t, err, "720h (exactly 30 days) should be accepted") + + // Validation: exceeds 30-day maximum (721h = 2_595_600_000 ms). + err = adminClient.UpdateChatWorkspaceTTL(ctx, codersdk.UpdateChatWorkspaceTTLRequest{ + WorkspaceTTLMillis: 2_595_600_000, + }) + requireSDKError(t, err, http.StatusBadRequest) +} + func requireSDKError(t *testing.T, err error, expectedStatus int) *codersdk.Error { t.Helper() diff --git a/coderd/coderd.go b/coderd/coderd.go index 0518dbb6d8..fba9d2f836 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1177,6 +1177,8 @@ func New(options *Options) *API { r.Put("/desktop-enabled", api.putChatDesktopEnabled) r.Get("/user-prompt", api.getUserChatCustomPrompt) r.Put("/user-prompt", api.putUserChatCustomPrompt) + r.Get("/workspace-ttl", api.getChatWorkspaceTTL) + r.Put("/workspace-ttl", api.putChatWorkspaceTTL) }) // TODO(cian): place under /api/experimental/chats/config r.Route("/providers", func(r chi.Router) { diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index e6601fe831..55e99ff30f 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2676,6 +2676,16 @@ func (q *querier) GetChatUsageLimitUserOverride(ctx context.Context, userID uuid return q.db.GetChatUsageLimitUserOverride(ctx, userID) } +func (q *querier) GetChatWorkspaceTTL(ctx context.Context) (string, error) { + // The workspace-TTL setting is a deployment-wide value read by any + // authenticated chat user. We only require that an explicit actor is + // present in the context so unauthenticated calls fail closed. + if _, ok := ActorFromContext(ctx); !ok { + return "", ErrNoActor + } + return q.db.GetChatWorkspaceTTL(ctx) +} + func (q *querier) GetChats(ctx context.Context, arg database.GetChatsParams) ([]database.Chat, error) { prep, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceChat.Type) if err != nil { @@ -6765,6 +6775,14 @@ func (q *querier) UpsertChatUsageLimitUserOverride(ctx context.Context, arg data return q.db.UpsertChatUsageLimitUserOverride(ctx, arg) } +//nolint:revive // Parameter name matches the generated querier interface. +func (q *querier) UpsertChatWorkspaceTTL(ctx context.Context, workspaceTtl string) error { + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { + return err + } + return q.db.UpsertChatWorkspaceTTL(ctx, workspaceTtl) +} + func (q *querier) UpsertConnectionLog(ctx context.Context, arg database.UpsertConnectionLogParams) (database.ConnectionLog, error) { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceConnectionLog); err != nil { return database.ConnectionLog{}, err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 6d7ea9932b..7a49bec63a 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -656,6 +656,10 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().GetChatDesktopEnabled(gomock.Any()).Return(false, nil).AnyTimes() check.Args().Asserts() })) + s.Run("GetChatWorkspaceTTL", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetChatWorkspaceTTL(gomock.Any()).Return("1h", nil).AnyTimes() + check.Args().Asserts() + })) s.Run("GetEnabledChatModelConfigs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { configA := testutil.Fake(s.T(), faker, database.ChatModelConfig{}) configB := testutil.Fake(s.T(), faker, database.ChatModelConfig{}) @@ -869,6 +873,10 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().UpsertChatDesktopEnabled(gomock.Any(), false).Return(nil).AnyTimes() check.Args(false).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) })) + s.Run("UpsertChatWorkspaceTTL", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().UpsertChatWorkspaceTTL(gomock.Any(), "1h").Return(nil).AnyTimes() + check.Args("1h").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) + })) s.Run("GetUserChatSpendInPeriod", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { arg := database.GetUserChatSpendInPeriodParams{ UserID: uuid.New(), diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 40ad46275a..26d73300ec 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1216,6 +1216,14 @@ func (m queryMetricsStore) GetChatUsageLimitUserOverride(ctx context.Context, us return r0, r1 } +func (m queryMetricsStore) GetChatWorkspaceTTL(ctx context.Context) (string, error) { + start := time.Now() + r0, r1 := m.s.GetChatWorkspaceTTL(ctx) + m.queryLatencies.WithLabelValues("GetChatWorkspaceTTL").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatWorkspaceTTL").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetChats(ctx context.Context, arg database.GetChatsParams) ([]database.Chat, error) { start := time.Now() r0, r1 := m.s.GetChats(ctx, arg) @@ -4768,6 +4776,14 @@ func (m queryMetricsStore) UpsertChatUsageLimitUserOverride(ctx context.Context, return r0, r1 } +func (m queryMetricsStore) UpsertChatWorkspaceTTL(ctx context.Context, workspaceTtl string) error { + start := time.Now() + r0 := m.s.UpsertChatWorkspaceTTL(ctx, workspaceTtl) + m.queryLatencies.WithLabelValues("UpsertChatWorkspaceTTL").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertChatWorkspaceTTL").Inc() + return r0 +} + func (m queryMetricsStore) UpsertConnectionLog(ctx context.Context, arg database.UpsertConnectionLogParams) (database.ConnectionLog, error) { start := time.Now() r0, r1 := m.s.UpsertConnectionLog(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 0df5aed2f1..bbed56add3 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -2224,6 +2224,21 @@ func (mr *MockStoreMockRecorder) GetChatUsageLimitUserOverride(ctx, userID any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatUsageLimitUserOverride", reflect.TypeOf((*MockStore)(nil).GetChatUsageLimitUserOverride), ctx, userID) } +// GetChatWorkspaceTTL mocks base method. +func (m *MockStore) GetChatWorkspaceTTL(ctx context.Context) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatWorkspaceTTL", ctx) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatWorkspaceTTL indicates an expected call of GetChatWorkspaceTTL. +func (mr *MockStoreMockRecorder) GetChatWorkspaceTTL(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatWorkspaceTTL", reflect.TypeOf((*MockStore)(nil).GetChatWorkspaceTTL), ctx) +} + // GetChats mocks base method. func (m *MockStore) GetChats(ctx context.Context, arg database.GetChatsParams) ([]database.Chat, error) { m.ctrl.T.Helper() @@ -8909,6 +8924,20 @@ func (mr *MockStoreMockRecorder) UpsertChatUsageLimitUserOverride(ctx, arg any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatUsageLimitUserOverride", reflect.TypeOf((*MockStore)(nil).UpsertChatUsageLimitUserOverride), ctx, arg) } +// UpsertChatWorkspaceTTL mocks base method. +func (m *MockStore) UpsertChatWorkspaceTTL(ctx context.Context, workspaceTtl string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpsertChatWorkspaceTTL", ctx, workspaceTtl) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpsertChatWorkspaceTTL indicates an expected call of UpsertChatWorkspaceTTL. +func (mr *MockStoreMockRecorder) UpsertChatWorkspaceTTL(ctx, workspaceTtl any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatWorkspaceTTL", reflect.TypeOf((*MockStore)(nil).UpsertChatWorkspaceTTL), ctx, workspaceTtl) +} + // UpsertConnectionLog mocks base method. func (m *MockStore) UpsertConnectionLog(ctx context.Context, arg database.UpsertConnectionLogParams) (database.ConnectionLog, error) { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 477f66d6c4..49a8550b68 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -255,6 +255,9 @@ type sqlcQuerier interface { GetChatUsageLimitConfig(ctx context.Context) (ChatUsageLimitConfig, error) GetChatUsageLimitGroupOverride(ctx context.Context, groupID uuid.UUID) (GetChatUsageLimitGroupOverrideRow, error) GetChatUsageLimitUserOverride(ctx context.Context, userID uuid.UUID) (GetChatUsageLimitUserOverrideRow, error) + // Returns the global TTL for chat workspaces as a Go duration string. + // Returns "0s" (disabled) when no value has been configured. + GetChatWorkspaceTTL(ctx context.Context) (string, error) GetChats(ctx context.Context, arg GetChatsParams) ([]Chat, error) GetConnectionLogsOffset(ctx context.Context, arg GetConnectionLogsOffsetParams) ([]GetConnectionLogsOffsetRow, error) GetCryptoKeyByFeatureAndSequence(ctx context.Context, arg GetCryptoKeyByFeatureAndSequenceParams) (CryptoKey, error) @@ -923,6 +926,7 @@ type sqlcQuerier interface { UpsertChatUsageLimitConfig(ctx context.Context, arg UpsertChatUsageLimitConfigParams) (ChatUsageLimitConfig, error) UpsertChatUsageLimitGroupOverride(ctx context.Context, arg UpsertChatUsageLimitGroupOverrideParams) (UpsertChatUsageLimitGroupOverrideRow, error) UpsertChatUsageLimitUserOverride(ctx context.Context, arg UpsertChatUsageLimitUserOverrideParams) (UpsertChatUsageLimitUserOverrideRow, error) + UpsertChatWorkspaceTTL(ctx context.Context, workspaceTtl string) error UpsertConnectionLog(ctx context.Context, arg UpsertConnectionLogParams) (ConnectionLog, error) // The default proxy is implied and not actually stored in the database. // So we need to store it's configuration here for display purposes. diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 41fc0175b9..a2c26c0254 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -16828,6 +16828,23 @@ func (q *sqlQuerier) GetChatSystemPrompt(ctx context.Context) (string, error) { return chat_system_prompt, err } +const getChatWorkspaceTTL = `-- name: GetChatWorkspaceTTL :one +SELECT + COALESCE( + (SELECT value FROM site_configs WHERE key = 'agents_workspace_ttl'), + '0s' + )::text AS workspace_ttl +` + +// Returns the global TTL for chat workspaces as a Go duration string. +// Returns "0s" (disabled) when no value has been configured. +func (q *sqlQuerier) GetChatWorkspaceTTL(ctx context.Context) (string, error) { + row := q.db.QueryRowContext(ctx, getChatWorkspaceTTL) + var workspace_ttl string + err := row.Scan(&workspace_ttl) + return workspace_ttl, err +} + const getDERPMeshKey = `-- name: GetDERPMeshKey :one SELECT value FROM site_configs WHERE key = 'derp_mesh_key' ` @@ -17042,6 +17059,19 @@ func (q *sqlQuerier) UpsertChatSystemPrompt(ctx context.Context, value string) e return err } +const upsertChatWorkspaceTTL = `-- name: UpsertChatWorkspaceTTL :exec +INSERT INTO site_configs (key, value) +VALUES ('agents_workspace_ttl', $1::text) +ON CONFLICT (key) DO UPDATE +SET value = $1::text +WHERE site_configs.key = 'agents_workspace_ttl' +` + +func (q *sqlQuerier) UpsertChatWorkspaceTTL(ctx context.Context, workspaceTtl string) error { + _, err := q.db.ExecContext(ctx, upsertChatWorkspaceTTL, workspaceTtl) + return err +} + const upsertDefaultProxy = `-- name: UpsertDefaultProxy :exec INSERT INTO site_configs (key, value) VALUES diff --git a/coderd/database/queries/siteconfig.sql b/coderd/database/queries/siteconfig.sql index 4e33585c88..37b96a6594 100644 --- a/coderd/database/queries/siteconfig.sql +++ b/coderd/database/queries/siteconfig.sql @@ -160,3 +160,19 @@ SET value = CASE ELSE 'false' END WHERE site_configs.key = 'agents_desktop_enabled'; + +-- name: GetChatWorkspaceTTL :one +-- Returns the global TTL for chat workspaces as a Go duration string. +-- Returns "0s" (disabled) when no value has been configured. +SELECT + COALESCE( + (SELECT value FROM site_configs WHERE key = 'agents_workspace_ttl'), + '0s' + )::text AS workspace_ttl; + +-- name: UpsertChatWorkspaceTTL :exec +INSERT INTO site_configs (key, value) +VALUES ('agents_workspace_ttl', @workspace_ttl::text) +ON CONFLICT (key) DO UPDATE +SET value = @workspace_ttl::text +WHERE site_configs.key = 'agents_workspace_ttl'; diff --git a/codersdk/chats.go b/codersdk/chats.go index dea346682f..0d314b9f32 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -359,6 +359,42 @@ type UpdateChatDesktopEnabledRequest struct { EnableDesktop bool `json:"enable_desktop"` } +// DefaultChatWorkspaceTTL is the default TTL for chat workspaces. +// Zero means disabled — the template's own autostop setting applies. +const DefaultChatWorkspaceTTL = 0 + +// ChatWorkspaceTTLResponse is the response for getting the chat +// workspace TTL setting. +type ChatWorkspaceTTLResponse struct { + // WorkspaceTTLMillis is the workspace TTL in milliseconds. + // Zero means disabled — the template's own autostop setting applies. + WorkspaceTTLMillis int64 `json:"workspace_ttl_ms"` +} + +// UpdateChatWorkspaceTTLRequest is the request to update the chat +// workspace TTL setting. +type UpdateChatWorkspaceTTLRequest struct { + // WorkspaceTTLMillis is the workspace TTL in milliseconds. + // Zero means disabled — the template's own autostop setting applies. + WorkspaceTTLMillis int64 `json:"workspace_ttl_ms"` +} + +// ParseChatWorkspaceTTL parses a stored TTL string, returning the +// default when the value is empty. +func ParseChatWorkspaceTTL(s string) (time.Duration, error) { + if s == "" { + return DefaultChatWorkspaceTTL, nil + } + d, err := time.ParseDuration(s) + if err != nil { + return 0, xerrors.Errorf("invalid duration %q: %w", s, err) + } + if d < 0 { + return 0, xerrors.New("duration must be non-negative") + } + return d, nil +} + // ChatProviderConfigSource describes how a provider entry is sourced. type ChatProviderConfigSource string @@ -1336,6 +1372,33 @@ func (c *Client) UpdateChatDesktopEnabled(ctx context.Context, req UpdateChatDes return nil } +// GetChatWorkspaceTTL returns the configured chat workspace TTL. +func (c *Client) GetChatWorkspaceTTL(ctx context.Context) (ChatWorkspaceTTLResponse, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/experimental/chats/config/workspace-ttl", nil) + if err != nil { + return ChatWorkspaceTTLResponse{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return ChatWorkspaceTTLResponse{}, ReadBodyAsError(res) + } + var resp ChatWorkspaceTTLResponse + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + +// UpdateChatWorkspaceTTL updates the chat workspace TTL setting. +func (c *Client) UpdateChatWorkspaceTTL(ctx context.Context, req UpdateChatWorkspaceTTLRequest) error { + res, err := c.Request(ctx, http.MethodPut, "/api/experimental/chats/config/workspace-ttl", req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} + // UpdateUserChatCustomPrompt updates the user's custom chat prompt. func (c *Client) UpdateUserChatCustomPrompt(ctx context.Context, req UserChatCustomPrompt) (UserChatCustomPrompt, error) { res, err := c.Request(ctx, http.MethodPut, "/api/experimental/chats/config/user-prompt", req) diff --git a/codersdk/chats_test.go b/codersdk/chats_test.go index 1d919f2f0e..f15ce56b76 100644 --- a/codersdk/chats_test.go +++ b/codersdk/chats_test.go @@ -362,3 +362,35 @@ func TestChatCostSummary_JSONRoundTrip(t *testing.T) { require.NoError(t, err) require.Equal(t, original.TotalCostMicros, decoded.TotalCostMicros) } + +//nolint:tparallel,paralleltest +func TestParseChatWorkspaceTTL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want time.Duration + wantErr bool + }{ + {"Empty_ReturnsDefault", "", 0, false}, + {"ValidDuration_Hours", "2h", 2 * time.Hour, false}, + {"ValidDuration_HoursAndMinutes", "2h30m", 2*time.Hour + 30*time.Minute, false}, + {"ValidDuration_Minutes", "90m", 90 * time.Minute, false}, + {"Zero", "0s", 0, false}, + {"Negative", "-1h", 0, true}, + {"Invalid", "not-a-duration", 0, true}, + {"LargeDuration", "720h", 720 * time.Hour, false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := codersdk.ParseChatWorkspaceTTL(tc.input) + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} diff --git a/site/src/api/api.ts b/site/src/api/api.ts index dad63e9498..bd124236aa 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -3138,6 +3138,20 @@ class ApiMethods { await this.axios.put("/api/experimental/chats/config/desktop-enabled", req); }; + getChatWorkspaceTTL = + async (): Promise => { + const response = await this.axios.get( + "/api/experimental/chats/config/workspace-ttl", + ); + return response.data; + }; + + updateChatWorkspaceTTL = async ( + req: TypesGen.UpdateChatWorkspaceTTLRequest, + ): Promise => { + await this.axios.put("/api/experimental/chats/config/workspace-ttl", req); + }; + getUserChatCustomPrompt = async (): Promise => { const response = await this.axios.get( diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 24b930ff53..ac8ac5f641 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -406,6 +406,22 @@ export const updateChatDesktopEnabled = (queryClient: QueryClient) => ({ }, }); +const chatWorkspaceTTLKey = ["chat-workspace-ttl"] as const; + +export const chatWorkspaceTTL = () => ({ + queryKey: chatWorkspaceTTLKey, + queryFn: () => API.getChatWorkspaceTTL(), +}); + +export const updateChatWorkspaceTTL = (queryClient: QueryClient) => ({ + mutationFn: API.updateChatWorkspaceTTL, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: chatWorkspaceTTLKey, + }); + }, +}); + const chatUserCustomPromptKey = ["chat-user-custom-prompt"] as const; export const chatUserCustomPrompt = () => ({ diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 4cee4a630c..c61c988ea8 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1941,6 +1941,19 @@ export interface ChatUsageLimitStatus { readonly period_end?: string; } +// From codersdk/chats.go +/** + * ChatWorkspaceTTLResponse is the response for getting the chat + * workspace TTL setting. + */ +export interface ChatWorkspaceTTLResponse { + /** + * WorkspaceTTLMillis is the workspace TTL in milliseconds. + * Zero means disabled — the template's own autostop setting applies. + */ + readonly workspace_ttl_ms: number; +} + // From codersdk/client.go /** * CoderDesktopTelemetryHeader contains a JSON-encoded representation of Desktop telemetry @@ -2716,6 +2729,13 @@ export interface DebugProfileOptions { readonly Profiles: readonly string[]; } +// From codersdk/chats.go +/** + * DefaultChatWorkspaceTTL is the default TTL for chat workspaces. + * Zero means disabled — the template's own autostop setting applies. + */ +export const DefaultChatWorkspaceTTL = 0; + // From codersdk/externalauth.go export interface DeleteExternalAuthByIDResponse { /** @@ -6884,6 +6904,19 @@ export interface UpdateChatUsageLimitOverrideRequest { readonly spend_limit_micros: number; // Must be greater than 0. } +// From codersdk/chats.go +/** + * UpdateChatWorkspaceTTLRequest is the request to update the chat + * workspace TTL setting. + */ +export interface UpdateChatWorkspaceTTLRequest { + /** + * WorkspaceTTLMillis is the workspace TTL in milliseconds. + * Zero means disabled — the template's own autostop setting applies. + */ + readonly workspace_ttl_ms: number; +} + // From codersdk/updatecheck.go /** * UpdateCheckResponse contains information on the latest release of Coder. diff --git a/site/src/pages/AgentsPage/SettingsPageContent.stories.tsx b/site/src/pages/AgentsPage/SettingsPageContent.stories.tsx index 9b6891c110..3b321e3674 100644 --- a/site/src/pages/AgentsPage/SettingsPageContent.stories.tsx +++ b/site/src/pages/AgentsPage/SettingsPageContent.stories.tsx @@ -145,6 +145,10 @@ const meta = { spyOn(API, "updateUserChatCustomPrompt").mockResolvedValue({ custom_prompt: "", }); + spyOn(API, "getChatWorkspaceTTL").mockResolvedValue({ + workspace_ttl_ms: 0, + }); + spyOn(API, "updateChatWorkspaceTTL").mockResolvedValue(); }, } satisfies Meta; @@ -181,6 +185,114 @@ export const TogglesDesktop: Story = { }, }; +export const DefaultAutostopDefault: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByText("Default Autostop"); + // When disabled (0s), shows template-default copy. + await canvas.findByText(/stopped as configured by their templates/i); + + // DurationField renders a text input labeled "Default autostop". + const durationInput = await canvas.findByLabelText("Default autostop"); + + // Default is "0s" → 0 hours (disabled). + expect(durationInput).toHaveValue("0"); + + // Save button should be disabled (no local change). + const ttlForm = durationInput.closest("form")!; + const saveButton = within(ttlForm).getByRole("button", { name: "Save" }); + expect(saveButton).toBeDisabled(); + }, +}; + +export const DefaultAutostopCustomValue: Story = { + beforeEach: () => { + // 2h = 2 hours exactly, shows cleanly in DurationField. + spyOn(API, "getChatWorkspaceTTL").mockResolvedValue({ + workspace_ttl_ms: 7_200_000, + }); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const durationInput = await canvas.findByLabelText("Default autostop"); + + // Shows 2 hours from the mock. + expect(durationInput).toHaveValue("2"); + + // When non-zero, shows the duration in the description. + await canvas.findByText(/stopped after 2 hours of inactivity/i); + }, +}; + +export const DefaultAutostopSave: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const durationInput = await canvas.findByLabelText("Default autostop"); + const ttlForm = durationInput.closest("form")!; + const saveButton = within(ttlForm).getByRole("button", { name: "Save" }); + + // Change to 3 hours. + await userEvent.clear(durationInput); + await userEvent.type(durationInput, "3"); + + // Save button should now be enabled. + await waitFor(() => { + expect(saveButton).toBeEnabled(); + }); + + await userEvent.click(saveButton); + await waitFor(() => { + expect(API.updateChatWorkspaceTTL).toHaveBeenCalledWith({ + workspace_ttl_ms: 10_800_000, + }); + }); + }, +}; + +export const DefaultAutostopExceedsMax: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const durationInput = await canvas.findByLabelText("Default autostop"); + const ttlForm = durationInput.closest("form")!; + const saveButton = within(ttlForm).getByRole("button", { name: "Save" }); + + // Enter 721 hours (exceeds 30-day / 720h limit). + await userEvent.clear(durationInput); + await userEvent.type(durationInput, "721"); + + // Error helper text should appear. + await waitFor(() => { + expect(canvas.getByText(/must not exceed 30 days/i)).toBeInTheDocument(); + }); + + // Save button should be disabled despite the field being dirty. + expect(saveButton).toBeDisabled(); + }, +}; + +export const DefaultAutostopNotVisibleToNonAdmin: Story = { + args: { + canSetSystemPrompt: false, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Personal Instructions should be visible. + await canvas.findByText("Personal Instructions"); + + // Admin-only sections should not be present. + const ttlHeading = canvas.queryByText("Default Autostop"); + expect(ttlHeading).toBeNull(); + + const desktopHeading = canvas.queryByText("Virtual Desktop"); + expect(desktopHeading).toBeNull(); + }, +}; + // ── Usage tab stories ────────────────────────────────────────── export const UsageUserList: Story = { diff --git a/site/src/pages/AgentsPage/SettingsPageContent.tsx b/site/src/pages/AgentsPage/SettingsPageContent.tsx index 3ff9377b70..de4203f667 100644 --- a/site/src/pages/AgentsPage/SettingsPageContent.tsx +++ b/site/src/pages/AgentsPage/SettingsPageContent.tsx @@ -5,14 +5,17 @@ import { chatDesktopEnabled, chatSystemPrompt, chatUserCustomPrompt, + chatWorkspaceTTL, updateChatDesktopEnabled, updateChatSystemPrompt, + updateChatWorkspaceTTL, updateUserChatCustomPrompt, } from "api/queries/chats"; import { userByName } from "api/queries/users"; import type * as TypesGen from "api/typesGenerated"; import { AvatarData } from "components/Avatar/AvatarData"; import { Button } from "components/Button/Button"; +import { DurationField } from "components/DurationField/DurationField"; import { Link } from "components/Link/Link"; import { PaginationAmount } from "components/PaginationWidget/PaginationAmount"; import { PaginationWidgetBase } from "components/PaginationWidget/PaginationWidgetBase"; @@ -48,6 +51,7 @@ import { useSearchParams } from "react-router"; import TextareaAutosize from "react-textarea-autosize"; import { formatTokenCount } from "utils/analytics"; import { formatCostMicros } from "utils/currency"; +import { humanDuration } from "utils/time"; import { ChatCostSummaryView } from "./ChatCostSummaryView"; import { ChatModelAdminPanel } from "./ChatModelAdminPanel/ChatModelAdminPanel"; import { InsightsContent } from "./InsightsContent"; @@ -430,6 +434,13 @@ export const SettingsPageContent: FC = ({ isError: isSaveDesktopEnabledError, } = useMutation(updateChatDesktopEnabled(queryClient)); + const workspaceTTLQuery = useQuery(chatWorkspaceTTL()); + const { + mutate: saveWorkspaceTTL, + isPending: isSavingWorkspaceTTL, + isError: isSaveWorkspaceTTLError, + } = useMutation(updateChatWorkspaceTTL(queryClient)); + const serverPrompt = systemPromptQuery.data?.system_prompt ?? ""; const [localEdit, setLocalEdit] = useState(null); const systemPromptDraft = localEdit ?? serverPrompt; @@ -442,8 +453,18 @@ export const SettingsPageContent: FC = ({ const isUserPromptDirty = localUserEdit !== null && localUserEdit !== serverUserPrompt; const desktopEnabled = desktopEnabledQuery.data?.enable_desktop ?? false; + const serverTTLMs = workspaceTTLQuery.data?.workspace_ttl_ms ?? 0; + const [localTTLMs, setLocalTTLMs] = useState(null); + const ttlMs = localTTLMs ?? serverTTLMs; + const isTTLDirty = localTTLMs !== null && localTTLMs !== serverTTLMs; + const maxTTLMs = 30 * 24 * 60 * 60_000; // 30 days + const isTTLOverMax = ttlMs > maxTTLMs; const isDisabled = - isSavingSystemPrompt || isSavingUserPrompt || isSavingDesktopEnabled; + isSavingSystemPrompt || + isSavingUserPrompt || + isSavingDesktopEnabled || + isSavingWorkspaceTTL; + const isTTLLoading = workspaceTTLQuery.isLoading; const handleSaveSystemPrompt = useCallback( (event: FormEvent) => { @@ -469,6 +490,18 @@ export const SettingsPageContent: FC = ({ [isUserPromptDirty, userPromptDraft, saveUserPrompt], ); + const handleSaveChatWorkspaceTTL = useCallback( + (event: FormEvent) => { + event.preventDefault(); + if (!isTTLDirty) return; + saveWorkspaceTTL( + { workspace_ttl_ms: localTTLMs ?? 0 }, + { onSuccess: () => setLocalTTLMs(null) }, + ); + }, + [isTTLDirty, localTTLMs, saveWorkspaceTTL], + ); + return (
@@ -615,6 +648,54 @@ export const SettingsPageContent: FC = ({

)}
+
+
void handleSaveChatWorkspaceTTL(event)} + > +
+

+ Default Autostop +

+ +
+

+ {ttlMs === 0 + ? "Workspaces linked to chats will be stopped as configured by their templates. Active chats continuously extend the deadline." + : `Workspaces linked to chats will be stopped after ${humanDuration(ttlMs)} of inactivity. Active chats continuously extend the deadline.`} +

+ setLocalTTLMs(v)} + disabled={isDisabled || isTTLLoading} + error={isTTLOverMax} + helperText={ + isTTLOverMax + ? "Must not exceed 30 days (720 hours)." + : undefined + } + /> +
+ +
+ {isSaveWorkspaceTTLError && ( +

+ Failed to save autostop setting. +

+ )} + {workspaceTTLQuery.isError && ( +

+ Failed to load autostop setting. +

+ )} + )}