From 203899718f9bd3c6ce68efd0d23c9dcd62d7d891 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Fri, 20 Feb 2026 12:56:00 +1100 Subject: [PATCH] feat: remove agent workspaces limit (#21998) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In relation to [`internal#1281`](https://github.com/coder/internal/issues/1281) Managed agent workspace build limits are now advisory only. Breaching the limit no longer blocks workspace creation — it only surfaces a warning. - Removed hard-limit enforcement in `checkAIBuildUsage` so AI task builds are always permitted regardless of managed agent count. - Updated the license warning to remove "Further managed agent builds will be blocked." verbiage. - Updated tests to assert builds succeed beyond the limit instead of failing. - Removed the "Limit" display from the `ManagedAgentsConsumption` progress bar — the bar is now relative to the included allowance (soft limit) only, and turns orange when usage exceeds it. Bonus: - De-MUI'd `LicenseBannerView` — replaced Emotion CSS and MUI `Link` with Tailwind classes. - Added `highlight-orange` color token to the Tailwind theme. --- codersdk/licenses.go | 5 +- enterprise/coderd/coderd.go | 63 +------- enterprise/coderd/coderd_test.go | 32 +++-- enterprise/coderd/license/license.go | 2 +- enterprise/coderd/license/license_test.go | 2 +- site/src/api/typesGenerated.ts | 4 + site/src/index.css | 2 + .../LicenseBannerView.stories.tsx | 18 +++ .../LicenseBanner/LicenseBannerView.tsx | 136 +++++++++--------- .../ManagedAgentsConsumption.tsx | 61 ++------ site/tailwind.config.js | 1 + 11 files changed, 137 insertions(+), 189 deletions(-) diff --git a/codersdk/licenses.go b/codersdk/licenses.go index 4863aad60c..da90f92543 100644 --- a/codersdk/licenses.go +++ b/codersdk/licenses.go @@ -12,8 +12,9 @@ import ( ) const ( - LicenseExpiryClaim = "license_expires" - LicenseTelemetryRequiredErrorText = "License requires telemetry but telemetry is disabled" + LicenseExpiryClaim = "license_expires" + LicenseTelemetryRequiredErrorText = "License requires telemetry but telemetry is disabled" + LicenseManagedAgentLimitExceededWarningText = "You have built more workspaces with managed agents than your license allows." ) type AddLicenseRequest struct { diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 6b66adacda..508f4d8277 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -983,7 +983,13 @@ func (api *API) updateEntitlements(ctx context.Context) error { var _ wsbuilder.UsageChecker = &API{} -func (api *API) CheckBuildUsage(ctx context.Context, store database.Store, templateVersion *database.TemplateVersion, task *database.Task, transition database.WorkspaceTransition) (wsbuilder.UsageCheckResponse, error) { +func (api *API) CheckBuildUsage( + _ context.Context, + _ database.Store, + templateVersion *database.TemplateVersion, + _ *database.Task, + _ database.WorkspaceTransition, +) (wsbuilder.UsageCheckResponse, error) { // If the template version has an external agent, we need to check that the // license is entitled to this feature. if templateVersion.HasExternalAgent.Valid && templateVersion.HasExternalAgent.Bool { @@ -996,61 +1002,6 @@ func (api *API) CheckBuildUsage(ctx context.Context, store database.Store, templ } } - resp, err := api.checkAIBuildUsage(ctx, store, task, transition) - if err != nil { - return wsbuilder.UsageCheckResponse{}, err - } - if !resp.Permitted { - return resp, nil - } - - return wsbuilder.UsageCheckResponse{Permitted: true}, nil -} - -// checkAIBuildUsage validates AI-related usage constraints. It is a no-op -// unless the transition is "start" and the template version has an AI task. -func (api *API) checkAIBuildUsage(ctx context.Context, store database.Store, task *database.Task, transition database.WorkspaceTransition) (wsbuilder.UsageCheckResponse, error) { - // Only check AI usage rules for start transitions. - if transition != database.WorkspaceTransitionStart { - return wsbuilder.UsageCheckResponse{Permitted: true}, nil - } - - // If the template version doesn't have an AI task, we don't need to check usage. - if task == nil { - return wsbuilder.UsageCheckResponse{Permitted: true}, nil - } - - // When licensed, ensure we haven't breached the managed agent limit. - // Unlicensed deployments are allowed to use unlimited managed agents. - if api.Entitlements.HasLicense() { - managedAgentLimit, ok := api.Entitlements.Feature(codersdk.FeatureManagedAgentLimit) - if !ok || !managedAgentLimit.Enabled || managedAgentLimit.Limit == nil || managedAgentLimit.UsagePeriod == nil { - return wsbuilder.UsageCheckResponse{ - Permitted: false, - Message: "Your license is not entitled to managed agents. Please contact sales to continue using managed agents.", - }, nil - } - - // This check is intentionally not committed to the database. It's fine - // if it's not 100% accurate or allows for minor breaches due to build - // races. - // nolint:gocritic // Requires permission to read all usage events. - managedAgentCount, err := store.GetTotalUsageDCManagedAgentsV1(agpldbauthz.AsSystemRestricted(ctx), database.GetTotalUsageDCManagedAgentsV1Params{ - StartDate: managedAgentLimit.UsagePeriod.Start, - EndDate: managedAgentLimit.UsagePeriod.End, - }) - if err != nil { - return wsbuilder.UsageCheckResponse{}, xerrors.Errorf("get managed agent count: %w", err) - } - - if managedAgentCount >= *managedAgentLimit.Limit { - return wsbuilder.UsageCheckResponse{ - Permitted: false, - Message: "You have breached the managed agent limit in your license. Please contact sales to continue using managed agents.", - }, nil - } - } - return wsbuilder.UsageCheckResponse{Permitted: true}, nil } diff --git a/enterprise/coderd/coderd_test.go b/enterprise/coderd/coderd_test.go index fe4306e2b8..d31d947313 100644 --- a/enterprise/coderd/coderd_test.go +++ b/enterprise/coderd/coderd_test.go @@ -765,15 +765,20 @@ func TestManagedAgentLimit(t *testing.T) { require.NoError(t, err, "fetching AI workspace must succeed") coderdtest.AwaitWorkspaceBuildJobCompleted(t, cli, workspace.LatestBuild.ID) - // Create a second AI task, which should fail due to breaching the limit. - _, err = cli.CreateTask(ctx, owner.UserID.String(), codersdk.CreateTaskRequest{ + // Create a second AI task, which should succeed even though the limit is + // breached. Managed agent limits are advisory only and should never block + // workspace creation. + task2, err := cli.CreateTask(ctx, owner.UserID.String(), codersdk.CreateTaskRequest{ Name: namesgenerator.UniqueNameWith("-"), TemplateVersionID: aiTemplate.ActiveVersionID, TemplateVersionPresetID: uuid.Nil, Input: "hi", DisplayName: namesgenerator.UniqueName(), }) - require.ErrorContains(t, err, "You have breached the managed agent limit in your license") + require.NoError(t, err, "creating task beyond managed agent limit must succeed") + workspace2, err := cli.Workspace(ctx, task2.WorkspaceID.UUID) + require.NoError(t, err, "fetching AI workspace must succeed") + coderdtest.AwaitWorkspaceBuildJobCompleted(t, cli, workspace2.LatestBuild.ID) // Create a third workspace using the same template, which should succeed. workspace = coderdtest.CreateWorkspace(t, cli, aiTemplate.ID) @@ -784,12 +789,12 @@ func TestManagedAgentLimit(t *testing.T) { coderdtest.AwaitWorkspaceBuildJobCompleted(t, cli, workspace.LatestBuild.ID) } -func TestCheckBuildUsage_SkipsAIForNonStartTransitions(t *testing.T) { +func TestCheckBuildUsage_NeverBlocksOnManagedAgentLimit(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) defer ctrl.Finish() - // Prepare entitlements with a managed agent limit to enforce. + // Prepare entitlements with a managed agent limit. entSet := entitlements.New() entSet.Modify(func(e *codersdk.Entitlements) { e.HasLicense = true @@ -825,27 +830,24 @@ func TestCheckBuildUsage_SkipsAIForNonStartTransitions(t *testing.T) { TemplateVersionID: tv.ID, } - // Mock DB: expect exactly one count call for the "start" transition. + // Mock DB: no calls expected since managed agent limits are + // advisory only and no longer query the database at build time. mDB := dbmock.NewMockStore(ctrl) - mDB.EXPECT(). - GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()). - Times(1). - Return(int64(1), nil) // equal to limit -> should breach ctx := context.Background() - // Start transition: should be not permitted due to limit breach. + // Start transition: should be permitted even though the limit is + // breached. Managed agent limits are advisory only. startResp, err := eapi.CheckBuildUsage(ctx, mDB, tv, task, database.WorkspaceTransitionStart) require.NoError(t, err) - require.False(t, startResp.Permitted) - require.Contains(t, startResp.Message, "breached the managed agent limit") + require.True(t, startResp.Permitted) - // Stop transition: should be permitted and must not trigger additional DB calls. + // Stop transition: should also be permitted. stopResp, err := eapi.CheckBuildUsage(ctx, mDB, tv, task, database.WorkspaceTransitionStop) require.NoError(t, err) require.True(t, stopResp.Permitted) - // Delete transition: should be permitted and must not trigger additional DB calls. + // Delete transition: should also be permitted. deleteResp, err := eapi.CheckBuildUsage(ctx, mDB, tv, task, database.WorkspaceTransitionDelete) require.NoError(t, err) require.True(t, deleteResp.Permitted) diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index 08c76f2bd9..e0f7206c7e 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -578,7 +578,7 @@ func LicensesEntitlements( } if managedAgentCount >= *agentLimit.Limit { entitlements.Warnings = append(entitlements.Warnings, - "You have built more workspaces with managed agents than your license allows. Further managed agent builds will be blocked.") + codersdk.LicenseManagedAgentLimitExceededWarningText) } else if managedAgentCount >= softWarningThreshold { entitlements.Warnings = append(entitlements.Warnings, "You are approaching the managed agent limit in your license. Please refer to the Deployment Licenses page for more information.") diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go index 1ab7ffbcc0..a1184972bd 100644 --- a/enterprise/coderd/license/license_test.go +++ b/enterprise/coderd/license/license_test.go @@ -1265,7 +1265,7 @@ func TestLicenseEntitlements(t *testing.T) { }, AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) { assert.Len(t, entitlements.Warnings, 1) - assert.Equal(t, "You have built more workspaces with managed agents than your license allows. Further managed agent builds will be blocked.", entitlements.Warnings[0]) + assert.Equal(t, "You have built more workspaces with managed agents than your license allows.", entitlements.Warnings[0]) assertNoErrors(t, entitlements) feature := entitlements.Features[codersdk.FeatureManagedAgentLimit] diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 01156b827d..7d55fd621f 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2587,6 +2587,10 @@ export interface License { // From codersdk/licenses.go export const LicenseExpiryClaim = "license_expires"; +// From codersdk/licenses.go +export const LicenseManagedAgentLimitExceededWarningText = + "You have built more workspaces with managed agents than your license allows."; + // From codersdk/licenses.go export const LicenseTelemetryRequiredErrorText = "License requires telemetry but telemetry is disabled"; diff --git a/site/src/index.css b/site/src/index.css index a28cf19528..a1e9a15043 100644 --- a/site/src/index.css +++ b/site/src/index.css @@ -45,6 +45,7 @@ --radius: 0.5rem; --highlight-purple: 271, 61%, 35%; --highlight-green: 143 64% 24%; + --highlight-orange: 30 100% 54%; --highlight-grey: 240 5% 65%; --highlight-sky: 195, 61%, 22%; --highlight-red: 0 74% 42%; @@ -91,6 +92,7 @@ --overlay-default: 240 10% 4% / 80%; --highlight-purple: 269, 100%, 74%; --highlight-green: 141 79% 85%; + --highlight-orange: 31 100% 70%; --highlight-grey: 240 4% 46%; --highlight-sky: 188, 75%, 80%; --highlight-red: 0 91% 71%; diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx index faaf7c0e22..e8e6981152 100644 --- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx +++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx @@ -1,5 +1,6 @@ import { chromatic } from "testHelpers/chromatic"; import type { Meta, StoryObj } from "@storybook/react-vite"; +import { LicenseManagedAgentLimitExceededWarningText } from "api/typesGenerated"; import { LicenseBannerView } from "./LicenseBannerView"; const meta: Meta = { @@ -36,3 +37,20 @@ export const OneError: Story = { warnings: [], }, }; + +export const ManagedAgentLimitExceeded: Story = { + args: { + errors: [], + warnings: [LicenseManagedAgentLimitExceededWarningText], + }, +}; + +export const ManagedAgentLimitExceededWithOtherWarnings: Story = { + args: { + errors: [], + warnings: [ + LicenseManagedAgentLimitExceededWarningText, + "You have exceeded the number of seats in your license.", + ], + }, +}; diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx index ee91ee81b2..94293aa979 100644 --- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx +++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx @@ -1,32 +1,13 @@ import { - type CSSObject, - css, - type Interpolation, - type Theme, - useTheme, -} from "@emotion/react"; -import Link from "@mui/material/Link"; -import { LicenseTelemetryRequiredErrorText } from "api/typesGenerated"; + LicenseManagedAgentLimitExceededWarningText, + LicenseTelemetryRequiredErrorText, +} from "api/typesGenerated"; import { Expander } from "components/Expander/Expander"; +import { Link } from "components/Link/Link"; import { Pill } from "components/Pill/Pill"; -import { type FC, useState } from "react"; - -const Language = { - licenseIssue: "License Issue", - licenseIssues: (num: number): string => `${num} License Issues`, - upgrade: "Contact sales@coder.com.", - exception: "Contact sales@coder.com if you need an exception.", - exceeded: "It looks like you've exceeded some limits of your license.", - lessDetails: "Less", - moreDetails: "More", -}; - -const styles = { - leftContent: { - marginRight: 8, - marginLeft: 8, - }, -} satisfies Record>; +import { useState } from "react"; +import { cn } from "utils/cn"; +import { docs } from "utils/docs"; const formatMessage = (message: string) => { // If the message ends with an alphanumeric character, add a period. @@ -36,73 +17,92 @@ const formatMessage = (message: string) => { return message; }; +const messageLinkProps = ( + message: string, +): Pick, "href" | "children" | "target"> => { + if (message === LicenseManagedAgentLimitExceededWarningText) { + return { + href: docs("/ai-coder/ai-governance"), + children: "View AI Governance", + target: "_blank", + }; + } + if (message === LicenseTelemetryRequiredErrorText) { + return { + href: "mailto:sales@coder.com", + children: "Contact sales@coder.com if you need an exception.", + }; + } + return { + href: "mailto:sales@coder.com", + children: "Contact sales@coder.com.", + }; +}; + interface LicenseBannerViewProps { errors: readonly string[]; warnings: readonly string[]; } -export const LicenseBannerView: FC = ({ +export const LicenseBannerView: React.FC = ({ errors, warnings, }) => { - const theme = useTheme(); const [showDetails, setShowDetails] = useState(false); const isError = errors.length > 0; const messages = [...errors, ...warnings]; const type = isError ? "error" : "warning"; - const containerStyles = css` - ${theme.typography.body2 as CSSObject} - - display: flex; - align-items: center; - padding: 12px; - background-color: ${theme.roles[type].background}; - `; - - const textColor = theme.roles[type].text; - if (messages.length === 1) { + const [message] = messages; + return ( -
- {Language.licenseIssue} -
- {formatMessage(messages[0])} +
+ License Issue +
+ {formatMessage(message)}   - {messages[0] === LicenseTelemetryRequiredErrorText - ? Language.exception - : Language.upgrade} - + className={cn( + "font-medium", + isError ? "!text-content-destructive" : "!text-content-warning", + )} + {...messageLinkProps(message)} + />
); } return ( -
- {Language.licenseIssues(messages.length)} -
-
- {Language.exceeded} -   - - {Language.upgrade} - -
+
+ {`${messages.length} License Issues`} +
+
It looks like you've exceeded some limits of your license.
-
    +
      {messages.map((message) => ( -
    • - {formatMessage(message)} +
    • + {formatMessage(message)}  +
    • ))}
    diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/ManagedAgentsConsumption.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/ManagedAgentsConsumption.tsx index c887a7ac71..3f93354654 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/ManagedAgentsConsumption.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/ManagedAgentsConsumption.tsx @@ -10,6 +10,7 @@ import { Link } from "components/Link/Link"; import dayjs from "dayjs"; import { ChevronRightIcon } from "lucide-react"; import type { FC } from "react"; +import { cn } from "utils/cn"; import { docs } from "utils/docs"; interface ManagedAgentsConsumptionProps { @@ -39,7 +40,6 @@ export const ManagedAgentsConsumption: FC = ({ const usage = managedAgentFeature.actual; const included = managedAgentFeature.soft_limit; - const limit = managedAgentFeature.limit; const startDate = managedAgentFeature.usage_period?.start; const endDate = managedAgentFeature.usage_period?.end; @@ -47,12 +47,7 @@ export const ManagedAgentsConsumption: FC = ({ return ; } - if ( - included === undefined || - included < 0 || - limit === undefined || - limit < 0 - ) { + if (included === undefined || included < 0) { return ; } @@ -66,9 +61,7 @@ export const ManagedAgentsConsumption: FC = ({ return ; } - const usagePercentage = Math.min((usage / limit) * 100, 100); - const includedPercentage = Math.min((included / limit) * 100, 100); - const remainingPercentage = Math.max(100 - includedPercentage, 0); + const usagePercentage = Math.min((usage / included) * 100, 100); return (
    @@ -132,20 +125,13 @@ export const ManagedAgentsConsumption: FC = ({ Amount of started workspaces with an AI agent.
  • -
    - Legend for included allowance -
    - Included allowance from your current license plan. -
  • -
  • -
    +
    - Legend for total limit in the chart + Legend for usage exceeding included allowance -
    - Total limit after which further AI workspace builds will be - blocked. + Usage has exceeded included allowance from your current license + plan.
@@ -162,17 +148,14 @@ export const ManagedAgentsConsumption: FC = ({
- -
@@ -181,20 +164,10 @@ export const ManagedAgentsConsumption: FC = ({ {usage.toLocaleString()}
-
+
Included: {included.toLocaleString()}
- -
- Limit: - {limit.toLocaleString()} -
@@ -203,14 +176,10 @@ export const ManagedAgentsConsumption: FC = ({ Actual: {usage.toLocaleString()}
-
+
Included: {included.toLocaleString()}
-
- Limit: - {limit.toLocaleString()} -
diff --git a/site/tailwind.config.js b/site/tailwind.config.js index fdc9bca4e0..01263a313f 100644 --- a/site/tailwind.config.js +++ b/site/tailwind.config.js @@ -74,6 +74,7 @@ module.exports = { highlight: { purple: "hsl(var(--highlight-purple))", green: "hsl(var(--highlight-green))", + orange: "hsl(var(--highlight-orange))", grey: "hsl(var(--highlight-grey))", sky: "hsl(var(--highlight-sky))", red: "hsl(var(--highlight-red))",