feat: remove agent workspaces limit (#21998)

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.
This commit is contained in:
Jake Howell
2026-02-20 12:56:00 +11:00
committed by GitHub
parent ccb5b83c19
commit 203899718f
11 changed files with 137 additions and 189 deletions
+3 -2
View File
@@ -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 {
+7 -56
View File
@@ -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
}
+17 -15
View File
@@ -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)
+1 -1
View File
@@ -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.")
+1 -1
View File
@@ -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]
+4
View File
@@ -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";
+2
View File
@@ -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%;
@@ -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<typeof LicenseBannerView> = {
@@ -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.",
],
},
};
@@ -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<string, Interpolation<Theme>>;
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<React.ComponentProps<typeof Link>, "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<LicenseBannerViewProps> = ({
export const LicenseBannerView: React.FC<LicenseBannerViewProps> = ({
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 (
<div css={containerStyles}>
<Pill type={type}>{Language.licenseIssue}</Pill>
<div css={styles.leftContent}>
<span>{formatMessage(messages[0])}</span>
<div
className={cn(
"flex items-center p-3 text-sm",
isError ? "bg-surface-red" : "bg-surface-orange",
)}
>
<Pill type={type}>License Issue</Pill>
<div className="mx-2">
<span>{formatMessage(message)}</span>
&nbsp;
<Link
color={textColor}
fontWeight="medium"
href="mailto:sales@coder.com"
>
{messages[0] === LicenseTelemetryRequiredErrorText
? Language.exception
: Language.upgrade}
</Link>
className={cn(
"font-medium",
isError ? "!text-content-destructive" : "!text-content-warning",
)}
{...messageLinkProps(message)}
/>
</div>
</div>
);
}
return (
<div css={containerStyles}>
<Pill type={type}>{Language.licenseIssues(messages.length)}</Pill>
<div css={styles.leftContent}>
<div>
{Language.exceeded}
&nbsp;
<Link
color={textColor}
fontWeight="medium"
href="mailto:sales@coder.com"
>
{Language.upgrade}
</Link>
</div>
<div
className={cn(
"flex items-center p-3 text-sm",
isError ? "bg-surface-red" : "bg-surface-orange",
)}
>
<Pill type={type}>{`${messages.length} License Issues`}</Pill>
<div className="mx-2">
<div>It looks like you've exceeded some limits of your license.</div>
<Expander expanded={showDetails} setExpanded={setShowDetails}>
<ul css={{ padding: 8, margin: 0 }}>
<ul className="p-2 m-0">
{messages.map((message) => (
<li css={{ margin: 4 }} key={message}>
{formatMessage(message)}
<li className="m-1" key={message}>
{formatMessage(message)}&nbsp;
<Link
className={cn(
"font-medium text-xs px-0",
isError
? "!text-content-destructive"
: "!text-content-warning",
)}
{...messageLinkProps(message)}
/>
</li>
))}
</ul>
@@ -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<ManagedAgentsConsumptionProps> = ({
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<ManagedAgentsConsumptionProps> = ({
return <ErrorAlert error="Invalid usage data" />;
}
if (
included === undefined ||
included < 0 ||
limit === undefined ||
limit < 0
) {
if (included === undefined || included < 0) {
return <ErrorAlert error="Invalid license usage limits" />;
}
@@ -66,9 +61,7 @@ export const ManagedAgentsConsumption: FC<ManagedAgentsConsumptionProps> = ({
return <ErrorAlert error="Invalid license usage period" />;
}
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 (
<section className="border border-solid rounded">
@@ -132,20 +125,13 @@ export const ManagedAgentsConsumption: FC<ManagedAgentsConsumptionProps> = ({
Amount of started workspaces with an AI agent.
</li>
<li className="flex items-center gap-2">
<div className="rounded-[2px] bg-content-disabled size-3 inline-block">
<span className="sr-only">Legend for included allowance</span>
</div>
Included allowance from your current license plan.
</li>
<li className="flex items-center gap-2">
<div className="size-3 inline-flex items-center justify-center">
<div className="rounded-[2px] bg-highlight-orange size-3 inline-block">
<span className="sr-only">
Legend for total limit in the chart
Legend for usage exceeding included allowance
</span>
<div className="w-full border-b-1 border-t-1 border-dashed border-content-disabled" />
</div>
Total limit after which further AI workspace builds will be
blocked.
Usage has exceeded included allowance from your current license
plan.
</li>
</ul>
</CollapsibleContent>
@@ -162,17 +148,14 @@ export const ManagedAgentsConsumption: FC<ManagedAgentsConsumptionProps> = ({
<div className="relative h-6 bg-surface-secondary rounded overflow-hidden">
<div
className="absolute top-0 left-0 h-full bg-highlight-green transition-all duration-300"
className={cn(
"absolute top-0 left-0 h-full transition-all duration-300",
usagePercentage < 100
? "bg-highlight-green"
: "bg-highlight-orange",
)}
style={{ width: `${usagePercentage}%` }}
/>
<div
className="absolute top-0 h-full bg-content-disabled opacity-30"
style={{
left: `${includedPercentage}%`,
width: `${remainingPercentage}%`,
}}
/>
</div>
<div className="relative hidden lg:flex justify-between mt-4 text-sm">
@@ -181,20 +164,10 @@ export const ManagedAgentsConsumption: FC<ManagedAgentsConsumptionProps> = ({
<span className="font-medium">{usage.toLocaleString()}</span>
</div>
<div
className="absolute flex flex-col items-center transform -translate-x-1/2"
style={{
left: `${Math.max(Math.min(includedPercentage, 90), 10)}%`,
}}
>
<div className="flex flex-col items-end">
<span className="text-content-secondary">Included:</span>
<span className="font-medium">{included.toLocaleString()}</span>
</div>
<div className="flex flex-col items-end">
<span className="text-content-secondary">Limit:</span>
<span className="font-medium">{limit.toLocaleString()}</span>
</div>
</div>
<div className="flex lg:hidden flex-col gap-3 mt-4 text-sm">
@@ -203,14 +176,10 @@ export const ManagedAgentsConsumption: FC<ManagedAgentsConsumptionProps> = ({
<span className="text-content-secondary">Actual:</span>
<span className="font-medium">{usage.toLocaleString()}</span>
</div>
<div className="flex flex-col items-center">
<div className="flex flex-col items-end">
<span className="text-content-secondary">Included:</span>
<span className="font-medium">{included.toLocaleString()}</span>
</div>
<div className="flex flex-col items-end">
<span className="text-content-secondary">Limit:</span>
<span className="font-medium">{limit.toLocaleString()}</span>
</div>
</div>
</div>
</div>
+1
View File
@@ -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))",